diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7a62303c..dcae324c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,31 +20,31 @@ on: jobs: build: - name: Build Debug + name: Test, lint and build runs-on: ubuntu-latest steps: - name: Checkout selected branch if: github.event_name == 'workflow_dispatch' - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ inputs.branch }} submodules: recursive - name: Checkout if: github.event_name != 'workflow_dispatch' - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: submodules: recursive - name: Set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 with: distribution: temurin java-version: "17" - name: Set up Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 - name: Install SDK components run: | @@ -56,31 +56,40 @@ jobs: echo "$ANDROID_HOME/ndk/28.2.13676358/toolchains/llvm/prebuilt/linux-x86_64/bin" >> $GITHUB_PATH - name: Set up Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c with: + toolchain: 1.94.0 targets: aarch64-linux-android + components: clippy - name: Install cargo-ndk - run: cargo install cargo-ndk + run: cargo install cargo-ndk --version 4.1.2 --locked - name: Set Gradle permission run: chmod +x ./gradlew - name: Cache Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@0b6dd653ba04f4f93bf581ec31e66cbd7dcb644d # v4 - name: Cache Cargo - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2 with: workspaces: | sdk GuiXu-Rust - - name: Build debug APK - run: ./gradlew :app:assembleDebug --stacktrace + - name: Test Rust SDK + run: | + cargo test --manifest-path sdk/Cargo.toml + # The pinned SDK still contains a known unsupported look-ahead regex. + # Keep every other Clippy check active until that SDK fix is merged. + cargo clippy --manifest-path sdk/Cargo.toml --all-targets -- --allow clippy::invalid_regex + + - name: Test, lint and build Android + run: ./gradlew :app:testDebugUnitTest :app:lintRelease :app:assembleDebug :app:assembleRelease --stacktrace - name: Upload debug APK - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: AHUTong-debug-apk path: app/build/outputs/apk/debug/*.apk diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8bd72cfa..f9df104e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,17 +16,9 @@ android { } } - packaging { - jniLibs { - excludes += "**/libahutong_rs.so" - } - } - - lint { - //即使报错也不会停止打包 - abortOnError = false - //打包release版本的时候是否进行检测 - checkReleaseBuilds = false + lint { + abortOnError = true + checkReleaseBuilds = true } //关闭PNG合法性检查 // aaptOptions.useNewCruncher = false @@ -135,7 +127,8 @@ dependencies { implementation(libs.androidx.ui) implementation(libs.androidx.foundation) implementation(libs.androidx.material.icons.extended) - implementation(libs.material3) + implementation(libs.material3) + implementation(libs.miuix.android) implementation(libs.androidx.runtime.livedata) implementation(libs.androidx.activity.compose) implementation(libs.androidx.navigation.compose) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index e3da384e..bc208b11 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -44,12 +44,12 @@ -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation -keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} -keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} --keep class com.ahu.ahutong.data.model.** { *; } +# These models are deserialized both from the native bridge and from local Gson caches. Field-only +# rules do not prevent vertical class merging, which is unsafe for reflection-based construction. +-keep class com.ahu.ahutong.data.model.** { *; } -keep class com.ahu.ahutong.ui.screen.main.ElectricityDepositKt { *; } -keep class com.ahu.ahutong.ui.screen.main.home.ElectricityPaymentKt { *; } -keep class com.ahu.ahutong.data.dao.AHUCache { *; } --keep class com.ahu.ahutong.ui.component.** { *; } --keep class com.ahu.ahutong.ui.state.** { *; } -keepclassmembers class kotlinx.coroutines.** { volatile ; @@ -74,7 +74,26 @@ private *; } --keep class com.ahu.ahutong.data.crawler.model.** { *; } +# Crawler DTOs are Retrofit/Gson wire contracts. Keeping only their fields still allows R8 to +# merge the owning classes, which can turn a valid login response into a ClassCastException in +# minified builds. Keep the complete contracts so Review/Release authentication behaves like Debug. +-keep class com.ahu.ahutong.data.crawler.model.** { *; } + +# Payment view models contain a small number of file-local wire DTOs. Keep only the DTO naming +# families rather than the ViewModels themselves, so R8 can still optimize the screen logic while +# Gson retains concrete constructors and field contracts in Review/Release builds. +-keep class com.ahu.ahutong.ui.state.*Response { *; } +-keep class com.ahu.ahutong.ui.state.*Map { *; } +-keep class com.ahu.ahutong.ui.state.*Data { *; } +-keep class com.ahu.ahutong.ui.state.*DataItem { *; } +-keep class com.ahu.ahutong.ui.state.*Details { *; } +-keep class com.ahu.ahutong.ui.state.*Payload { *; } +-keep class com.ahu.ahutong.ui.state.*FeeItem { *; } + +# Native campus-card WebView bridge messages are also Gson contracts. +-keep class com.ahu.ahutong.ui.screen.main.CmbRechargeBridgePayload { *; } +-keep class com.ahu.ahutong.ui.screen.main.CmbRechargeBridgePaymentMethod { *; } +-keep class com.ahu.ahutong.ui.screen.main.CmbPaymentUiPayload { *; } -renamesourcefileattribute AHUTong @@ -160,22 +179,14 @@ -keep class com.ahu.ahutong.personalization.bootstrap.BootstrapTrainingCredentialResponse { *; } -keep class com.ahu.ahutong.personalization.bootstrap.BootstrapTrainingDeletionRequest { *; } -# Data source interface + implementations (prevent R8 from stripping abstract methods) --keep interface com.ahu.ahutong.data.base.BaseDataSource { *; } --keep class com.ahu.ahutong.data.crawler.CrawlerDataSource { *; } --keep class com.ahu.ahutong.data.crawler.SdkDataSource { *; } --keep class com.ahu.ahutong.data.mock.MockDataSource { *; } - -# Weather API + models (prevent R8 from stripping Gson/Retrofit classes) --keep class com.ahu.ahutong.data.weather.** { *; } --keep interface com.ahu.ahutong.data.weather.WeatherApi { *; } - -# Repository / GitHub models --keep class com.ahu.ahutong.data.repository.** { *; } - -# AHURepository --keep class com.ahu.ahutong.data.AHURepository { *; } +# Weather responses are constructed and populated reflectively by Gson. Field-only rules with +# allowoptimization let R8 remove fields that are only read through reflection, which leaves the +# release weather widget with an incomplete response model. +-keep class com.ahu.ahutong.data.weather.** { *; } +-keep interface com.ahu.ahutong.data.weather.WeatherApi { *; } + +# Repository / GitHub models +-keepclassmembers,allowoptimization class com.ahu.ahutong.data.repository.** { ; } # Evaluation --keep class com.ahu.ahutong.data.EvaluationRepository { *; } -keep interface com.ahu.ahutong.data.crawler.api.jwxt.EvaluationApi { *; } diff --git a/app/src/debug/java/com/ahu/ahutong/personalization/diagnostics/DebugDiagnosticsContribution.kt b/app/src/debug/java/com/ahu/ahutong/personalization/diagnostics/DebugDiagnosticsContribution.kt index ad4d3254..458b69c0 100644 --- a/app/src/debug/java/com/ahu/ahutong/personalization/diagnostics/DebugDiagnosticsContribution.kt +++ b/app/src/debug/java/com/ahu/ahutong/personalization/diagnostics/DebugDiagnosticsContribution.kt @@ -50,6 +50,7 @@ import androidx.compose.runtime.LaunchedEffect 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.input.pointer.pointerInput import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -69,6 +70,10 @@ import com.ahu.ahutong.personalization.prefetch.PrefetchCoordinator import com.ahu.ahutong.personalization.prefetch.PrefetchDiagnostic import com.ahu.ahutong.personalization.prefetch.PrefetchState import com.ahu.ahutong.personalization.ui.SuggestionPolicy +import com.ahu.ahutong.ui.components.LocalLiquidGlassContentBackdrop +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import java.time.LocalDate import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -130,6 +135,7 @@ class DebugDiagnosticsContribution @Inject constructor( val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() } val screenHeightPx = with(density) { configuration.screenHeightDp.dp.toPx() } val ballPx = with(density) { 52.dp.toPx() } + val contentBackdrop = LocalLiquidGlassContentBackdrop.current val horizontalInsetPx = with(density) { 12.dp.toPx() } val minX = -(screenWidthPx - ballPx - horizontalInsetPx * 2).coerceAtLeast(0f) val maxY = (screenHeightPx / 2f - ballPx).coerceAtLeast(0f) @@ -143,7 +149,12 @@ class DebugDiagnosticsContribution @Inject constructor( .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) } .padding(end = 12.dp) .size(52.dp) - .clip(CircleShape) + .appLiquidGlassSurface( + shape = CircleShape, + fallbackColor = MaterialTheme.colorScheme.tertiaryContainer, + level = LiquidGlassSurfaceLevel.Floating, + backdrop = contentBackdrop + ) .pointerInput(minX, maxY) { detectDragGestures( onDragEnd = { @@ -161,7 +172,7 @@ class DebugDiagnosticsContribution @Inject constructor( onLongClick = preferences::togglePaused ), shape = CircleShape, - color = MaterialTheme.colorScheme.tertiaryContainer, + color = Color.Transparent, shadowElevation = 8.dp ) { Box(contentAlignment = Alignment.Center) { @@ -198,7 +209,9 @@ private fun DiagnosticsScreen( onDispose { activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } } LazyColumn( - modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background), + modifier = Modifier + .fillMaxSize() + .appLiquidGlassSceneBackground(MaterialTheme.colorScheme.background), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -844,11 +857,18 @@ private fun DiagnosticsSection( title: String? = null, content: @Composable ColumnScope.() -> Unit ) { + val shape = MaterialTheme.shapes.large Surface( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 1.dp + modifier = Modifier + .fillMaxWidth() + .appLiquidGlassSurface( + shape = shape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainer, + level = LiquidGlassSurfaceLevel.Panel + ), + shape = shape, + color = Color.Transparent, + tonalElevation = 0.dp ) { Column( modifier = Modifier.fillMaxWidth().padding(16.dp), diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/settings/Debug.kt b/app/src/debug/java/com/ahu/ahutong/ui/screen/settings/Debug.kt similarity index 97% rename from app/src/main/java/com/ahu/ahutong/ui/screen/settings/Debug.kt rename to app/src/debug/java/com/ahu/ahutong/ui/screen/settings/Debug.kt index 942e7e72..9d1665ad 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/settings/Debug.kt +++ b/app/src/debug/java/com/ahu/ahutong/ui/screen/settings/Debug.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme @@ -32,6 +33,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -47,6 +49,8 @@ import com.ahu.ahutong.data.gray.GrayReleaseManager import com.ahu.ahutong.data.mock.MockScenarioController import com.ahu.ahutong.notification.CourseReminderScheduler import com.ahu.ahutong.ui.components.LiquidToggle +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.DiscoveryViewModel import com.ahu.ahutong.ui.state.ScheduleViewModel @@ -165,6 +169,7 @@ fun Debug( Column( modifier = Modifier .fillMaxSize() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1) .verticalScroll(rememberScrollState()) .systemBarsPadding() .padding(bottom = 80.dp) @@ -490,8 +495,9 @@ fun Debug( unfocusedTextColor = MaterialTheme.colorScheme.onSurface, focusedLabelColor = MaterialTheme.colorScheme.primary, unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, + focusedContainerColor = subCardColor, + unfocusedContainerColor = subCardColor, + disabledContainerColor = subCardColor, cursorColor = MaterialTheme.colorScheme.primary ) ) @@ -769,11 +775,14 @@ private fun DebugSection( cardColor: Color, content: @Composable ColumnScope.() -> Unit ) { + val shape = SmoothRoundedCornerShape(24.dp) Column( modifier = Modifier .fillMaxWidth() - .clip(SmoothRoundedCornerShape(24.dp)) - .background(cardColor) + .appLiquidGlassSurface( + shape = shape, + fallbackColor = cardColor + ) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), content = { @@ -804,7 +813,11 @@ private fun DebugToggleRow( .fillMaxWidth() .clip(SmoothRoundedCornerShape(20.dp)) .background(96.n1 withNight 16.n1) - .clickable { onCheckedChange(!checked) } + .toggleable( + value = checked, + role = Role.Switch, + onValueChange = onCheckedChange + ) .padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically @@ -826,7 +839,8 @@ private fun DebugToggleRow( LiquidToggle( selected = { checked }, onSelect = onCheckedChange, - backdrop = backdrop + backdrop = backdrop, + toggleOnTap = false ) } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d5adc922..b0e45d3d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,7 +7,6 @@ - @@ -128,7 +127,7 @@ + android:exported="false"> diff --git a/app/src/main/java/com/ahu/ahutong/AHUApplication.java b/app/src/main/java/com/ahu/ahutong/AHUApplication.java index ee16cbe1..3b3b1fc1 100644 --- a/app/src/main/java/com/ahu/ahutong/AHUApplication.java +++ b/app/src/main/java/com/ahu/ahutong/AHUApplication.java @@ -48,8 +48,11 @@ public void onCreate() { CourseReminderScheduler.INSTANCE.createNotificationChannel(this); CourseReminderScheduler.INSTANCE.reschedule(this); - // 初始化数据源(根据 Mock 开关) - if(AHUCache.INSTANCE.getMockData()){ + // Release builds always start on the real data source and erase legacy mock state. + if (!BuildConfig.DEBUG) { + AHUCache.INSTANCE.setMockData(false); + AHURepository.INSTANCE.initializeDataSource(false); + } else if(AHUCache.INSTANCE.getMockData()){ AHURepository.INSTANCE.initializeDataSource(true); Toast.makeText(this,"正在使用mock数据",Toast.LENGTH_SHORT).show(); } diff --git a/app/src/main/java/com/ahu/ahutong/MainActivity.kt b/app/src/main/java/com/ahu/ahutong/MainActivity.kt index 6ae19776..356363b4 100644 --- a/app/src/main/java/com/ahu/ahutong/MainActivity.kt +++ b/app/src/main/java/com/ahu/ahutong/MainActivity.kt @@ -31,7 +31,7 @@ import com.ahu.ahutong.sdk.LocalServiceClient import com.ahu.ahutong.sdk.RustSDK import com.ahu.ahutong.ui.component.ApkMirrorSourceDialog import com.ahu.ahutong.ui.component.ApkUpdateDialog -import com.ahu.ahutong.ui.screen.Main +import com.ahu.ahutong.ui.screen.Main import com.ahu.ahutong.ui.state.AboutViewModel import com.ahu.ahutong.ui.state.DiscoveryViewModel import com.ahu.ahutong.ui.state.LoginViewModel @@ -46,9 +46,12 @@ import com.ahu.ahutong.personalization.runtime.BehaviorPredictionRuntime import com.ahu.ahutong.personalization.action.ActionSource import java.io.File import java.security.MessageDigest +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext private const val DEBUG_BUILD_NOTICE_DURATION_MS = 3_000L +private const val STARTUP_BACKGROUND_WORK_DELAY_MS = 250L @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -68,10 +71,9 @@ class MainActivity : ComponentActivity() { @OptIn(ExperimentalAnimationApi::class) override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() + super.onCreate(savedInstanceState) + enableEdgeToEdge() initializeActivityResultLauncher() - init() if (intent?.data != null) behaviorRuntime.markNextNavigationSource(ActionSource.DEEPLINK) setContent { @@ -156,28 +158,24 @@ class MainActivity : ComponentActivity() { } } + init() showDebugBuildNotice(savedInstanceState) } private fun init() { lifecycleScope.launchSafe { + // Let Compose draw the cached first screen before starting native services, + // widget scheduling and network refreshes. + delay(STARTUP_BACKGROUND_WORK_DELAY_MS) if (AHUCache.isPrivacyAccepted()) { AHUCache.getCurrentUser()?.xh?.takeIf { it.isNotBlank() }?.let { behaviorRuntime.startProfile(it) } } - } - if (!BuildConfig.DEBUG) { - lifecycleScope.launchSafe { - mainViewModel.checkApkUpdate(this@MainActivity) - } - } - WidgetUpdateScheduler.scheduleNext(this@MainActivity) - - RustSDK.loadLibrary(context = applicationContext) - // 在 native library 加载后启动本地 HTTP 服务 - val storageInitialized = startLocalService() - - lifecycleScope.launchSafe { + val storageInitialized = withContext(Dispatchers.IO) { + WidgetUpdateScheduler.scheduleNext(this@MainActivity) + RustSDK.loadLibrary(context = applicationContext) + startLocalService() + } if (!storageInitialized) { restoreRustCookies() } @@ -190,6 +188,10 @@ class MainActivity : ComponentActivity() { scheduleViewModel.loadConfig() scheduleViewModel.refreshSchedule() } + + if (!BuildConfig.DEBUG) { + mainViewModel.checkApkUpdate(this@MainActivity) + } } } diff --git a/app/src/main/java/com/ahu/ahutong/appwidget/AdaptiveTestWidgetProvider.kt b/app/src/main/java/com/ahu/ahutong/appwidget/AdaptiveTestWidgetProvider.kt index 9fc8d346..e09b5c2d 100644 --- a/app/src/main/java/com/ahu/ahutong/appwidget/AdaptiveTestWidgetProvider.kt +++ b/app/src/main/java/com/ahu/ahutong/appwidget/AdaptiveTestWidgetProvider.kt @@ -8,6 +8,7 @@ import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.graphics.Color +import android.os.Build import android.util.Log import android.view.View import android.widget.RemoteViews @@ -163,7 +164,11 @@ class ScheduleAdaptiveWidgetProvider : AppWidgetProvider() { remoteViews.setTextColor(titleId, widgetColors.primaryText.toArgb()) remoteViews.setTextColor(subtitleId, widgetColors.secondaryText.toArgb()) remoteViews.removeAllViews(itemsContainerId) - remoteViews.setColorStateList(R.id.layout_wight, "setBackgroundTintList", ColorStateList.valueOf(widgetColors.background.toArgb())) + setRemoteViewBackgroundColor( + remoteViews, + R.id.layout_wight, + widgetColors.background.toArgb() + ) if (displayCourses.isEmpty()) { val emptyItem = RemoteViews(context.packageName, R.layout.layout_widget_item) emptyItem.setViewVisibility(R.id.little_circle, View.GONE) @@ -174,7 +179,11 @@ class ScheduleAdaptiveWidgetProvider : AppWidgetProvider() { emptyItem.setTextColor(R.id.course_name_tv, widgetColors.primaryText.toArgb()) emptyItem.setTextColor(R.id.course_time_tv, widgetColors.secondaryText.toArgb()) emptyItem.setTextColor(R.id.course_location_tv, widgetColors.secondaryText.toArgb()) - emptyItem.setColorStateList(R.id.widget_item_color_bg, "setBackgroundTintList", ColorStateList.valueOf(Color.TRANSPARENT)) + setRemoteViewBackgroundColor( + emptyItem, + R.id.widget_item_color_bg, + Color.TRANSPARENT + ) remoteViews.addView(itemsContainerId, emptyItem) } else { displayCourses.forEach { @@ -206,13 +215,10 @@ class ScheduleAdaptiveWidgetProvider : AppWidgetProvider() { if (isOngoing) widgetColors.ongoingSecondaryText.toArgb() else widgetColors.secondaryText.toArgb() ) - item.setColorStateList( + setRemoteViewBackgroundColor( + item, R.id.widget_item_color_bg, - "setBackgroundTintList", - ColorStateList.valueOf( - if (isOngoing) widgetColors.activatedRow.toArgb() - else Color.TRANSPARENT - ) + if (isOngoing) widgetColors.activatedRow.toArgb() else Color.TRANSPARENT ) remoteViews.addView(itemsContainerId, item) } @@ -220,6 +226,22 @@ class ScheduleAdaptiveWidgetProvider : AppWidgetProvider() { appWidgetManager.updateAppWidget(appWidgetId, remoteViews) } + private fun setRemoteViewBackgroundColor( + remoteViews: RemoteViews, + viewId: Int, + color: Int + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + remoteViews.setColorStateList( + viewId, + "setBackgroundTintList", + ColorStateList.valueOf(color) + ) + } else { + remoteViews.setInt(viewId, "setBackgroundColor", color) + } + } + private fun createRefreshPendingIntent(context: Context, appWidgetId: Int): PendingIntent { val intent = Intent(context, ScheduleAdaptiveWidgetProvider::class.java).apply { action = ACTION_REFRESH diff --git a/app/src/main/java/com/ahu/ahutong/appwidget/WidgetColorEngine.kt b/app/src/main/java/com/ahu/ahutong/appwidget/WidgetColorEngine.kt index f31d95db..24e5250a 100644 --- a/app/src/main/java/com/ahu/ahutong/appwidget/WidgetColorEngine.kt +++ b/app/src/main/java/com/ahu/ahutong/appwidget/WidgetColorEngine.kt @@ -51,7 +51,7 @@ inline val Number.n2: Color object MonetEngine { var palettes: TonalPalettes = - Color(android.R.color.holo_blue_bright).toTonalPalettes() + Color(0xFF00DDFF).toTonalPalettes() } diff --git a/app/src/main/java/com/ahu/ahutong/appwidget/WidgetUpdateScheduler.kt b/app/src/main/java/com/ahu/ahutong/appwidget/WidgetUpdateScheduler.kt index cd7fc252..e56bf9a5 100644 --- a/app/src/main/java/com/ahu/ahutong/appwidget/WidgetUpdateScheduler.kt +++ b/app/src/main/java/com/ahu/ahutong/appwidget/WidgetUpdateScheduler.kt @@ -13,6 +13,7 @@ import androidx.glance.appwidget.updateAll import com.ahu.ahutong.data.debug.DebugClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import java.time.LocalDateTime import java.time.ZoneId @@ -27,11 +28,14 @@ class WidgetUpdateScheduler : BroadcastReceiver() { Log.e(TAG, "onReceive: Triggering widget update (Test Mode)") // 1. Update Glance Widget - CoroutineScope(Dispatchers.IO).launch { + val pendingResult = goAsync() + receiverScope.launch { try { ScheduleAppWidget().updateAll(context) } catch (e: Exception) { Log.e(TAG, "Failed to update Glance widget", e) + } finally { + pendingResult.finish() } } @@ -57,6 +61,7 @@ class WidgetUpdateScheduler : BroadcastReceiver() { private const val TAG = "WidgetUpdateScheduler" const val ACTION_UPDATE_WIDGETS = "com.ahu.ahutong.appwidget.ACTION_UPDATE_WIDGETS" private const val REQUEST_CODE = 3001 + private val receiverScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) fun scheduleNext(context: Context) { val now = LocalDateTime.now() @@ -97,7 +102,7 @@ class WidgetUpdateScheduler : BroadcastReceiver() { pendingIntent ) } else { - alarmManager.setExactAndAllowWhileIdle( + alarmManager.setAndAllowWhileIdle( AlarmManager.RTC_WAKEUP, triggerMillis, pendingIntent diff --git a/app/src/main/java/com/ahu/ahutong/data/AHURepository.kt b/app/src/main/java/com/ahu/ahutong/data/AHURepository.kt index b3aee778..430c7e58 100644 --- a/app/src/main/java/com/ahu/ahutong/data/AHURepository.kt +++ b/app/src/main/java/com/ahu/ahutong/data/AHURepository.kt @@ -29,9 +29,11 @@ import com.ahu.ahutong.sdk.RustSDK import com.ahu.ahutong.utils.DES import com.google.gson.Gson import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.ResponseBody @@ -53,6 +55,7 @@ object AHURepository { WebVerificationRequired } + @Volatile private var dataSource: BaseDataSource = SdkDataSource() fun initializeDataSource(useMock: Boolean = AHUCache.getMockData()) { dataSource = if (useMock) MockDataSource() else SdkDataSource() @@ -92,18 +95,16 @@ object AHURepository { try { val response = dataSource.getSchedule() - - AHUCache.getSchoolTerm()?.let{ - AHUCache.saveSchedule(it,response.data) - } - - if (response.isSuccessful) { - Result.success(response.data) + val schedule = response.data + if (response.isSuccessful && schedule != null) { + AHUCache.getSchoolTerm()?.let { AHUCache.saveSchedule(it, schedule) } + Result.success(schedule) } else { - Result.failure(Throwable(response.msg)) + Result.failure(IllegalStateException(response.msg.ifBlank { "课表响应缺少数据" })) } } catch (e: Throwable) { + if (e is CancellationException) throw e Result.failure(e) } } @@ -118,13 +119,15 @@ object AHURepository { try { val response = dataSource.getNextSchedule() - if (response.isSuccessful) { - AHUCache.saveNextSchedule(response.data) - Result.success(response.data) + val schedule = response.data + if (response.isSuccessful && schedule != null) { + AHUCache.saveNextSchedule(schedule) + Result.success(schedule) } else { - Result.failure(Throwable(response.msg)) + Result.failure(IllegalStateException(response.msg.ifBlank { "下学期课表响应缺少数据" })) } } catch (e: Throwable) { + if (e is CancellationException) throw e Result.failure(e) } } @@ -266,9 +269,13 @@ object AHURepository { /** * 爬虫登录 */ - suspend fun loginWithCrawler(username: String, password: String): AHUResponse = + suspend fun loginWithCrawler( + username: String, + password: String, + preferNative: Boolean = true + ): AHUResponse = withContext(Dispatchers.IO) { - getHttpClient()?.let { httpClient -> + if (preferNative) getHttpClient()?.let { httpClient -> val result = AHUResponse() try { httpClient.init("") @@ -288,11 +295,12 @@ object AHURepository { Log.w(TAG, "Rust login failed, fallback to Android crawler", loginResult.exceptionOrNull()) } catch (e: Throwable) { + if (e is CancellationException) throw e Log.w(TAG, "Rust login threw, fallback to Android crawler", e) } } - if (RustSDK.isNativeLoaded()) { + if (preferNative && RustSDK.isNativeLoaded()) { val result = AHUResponse() try { RustSDK.initSafe("") @@ -312,49 +320,53 @@ object AHURepository { Log.w(TAG, "Rust JNI login failed, fallback to Android crawler", loginResult.exceptionOrNull()) } catch (e: Throwable) { + if (e is CancellationException) throw e Log.w(TAG, "Rust JNI login threw, fallback to Android crawler", e) } } val adwmhLogin = async(Dispatchers.IO) { - - var failedTimes = 0 - var info: Info? = null - // 二维码可能识别失败,尝试5次呢 - while (failedTimes < 5) { - Log.e(TAG, "loginWithCrawler: ${failedTimes+1} 登录", ) - val captchaBytes = AdwmhApi.API.getAuthCode().bytes() - Log.e(TAG, "loginWithCrawler: ${captchaBytes}", ) - val captchaPart = MultipartBody.Part.createFormData( - "captcha", "img.jpg", - captchaBytes.toRequestBody("image/jpg".toMediaType()) - ) - val captcha = AhuTong.API - .getCaptchaResult(captchaPart) - .result - - - Log.e(TAG, "loginWithCrawler: ${captcha}", ) - info = AdwmhApi.API.loginWithCaptcha( - username, - password, - 0, - captcha - ) - - if (info.code == 10000) { - Log.e(TAG, "loginWithCrawler: $info") - return@async info + try { + var failedTimes = 0 + var info: Info? = null + // Captcha recognition is fallible, so retry without letting one malformed + // response cancel the parallel JWXT session refresh. + while (failedTimes < 5) { + Log.e(TAG, "loginWithCrawler: ${failedTimes + 1} 登录") + val captchaBytes = AdwmhApi.LOGIN_API.getAuthCode().bytes() + val captchaPart = MultipartBody.Part.createFormData( + "captcha", "img.jpg", + captchaBytes.toRequestBody("image/jpg".toMediaType()) + ) + val captcha = AhuTong.API + .getCaptchaResult(captchaPart) + .result + + info = AdwmhApi.LOGIN_API.loginWithCaptcha( + username, + password, + 0, + captcha + ).use { body -> + Gson().fromJson(body.string(), Info::class.java) + } + + if (info?.code == 10000) { + Log.i(TAG, "Android crawler login succeeded") + return@async info + } + failedTimes++ } - failedTimes++ + info + } catch (e: Throwable) { + if (e is CancellationException) throw e + Log.w(TAG, "Android crawler login failed without cancelling JWXT refresh", e) + null } - - return@async info - } val jwxtLogin = async { - val loginPage = JwxtApi.API.fetchLoginInfo() + val loginPage = JwxtApi.LOGIN_API.fetchLoginInfo() val finalUrl = loginPage.raw().request.url.toString() if (loginPage.code() == WEB_VERIFICATION_REQUIRED_CODE) { @@ -381,18 +393,18 @@ object AHURepository { lt?.let { val cipher = DES().strEnc(username + password + lt, "1", "2", "3") - val res = JwxtApi.API.device( + val res = JwxtApi.LOGIN_API.device( "https://one.ahu.edu.cn/cas/device", username.length, password.length, cipher ) - Log.e(TAG, "loginWithCrawler: $res") + Log.d(TAG, "JWXT device handshake completed with HTTP ${res.code()}") val jwxtLoginUrl = "https://one.ahu.edu.cn/cas/login" + "?service=https%3A%2F%2Fjw.ahu.edu.cn%2Fstudent%2Fsso%2Flogin" - val jwxtResponse = JwxtApi.API.login( + val jwxtResponse = JwxtApi.LOGIN_API.login( jwxtLoginUrl, cipher, username.length, @@ -431,6 +443,7 @@ object AHURepository { } if (user != null && jwxtLoginResult == JwxtLoginResult.Succeeded) { + syncAndroidCookiesToRust() result.code = 0 result.data = user result.msg = "登录成功" @@ -441,6 +454,138 @@ object AHURepository { return@withContext result } + /** + * Restores the central CAS session for a concrete first-party service. A valid JWXT + * service cookie does not imply that the CAS TGC is still valid, so campus-card flows + * must authenticate the exact service URL instead of reloading the JWXT home page. + */ + suspend fun refreshCentralCasSession( + username: String, + password: String, + casLoginUrl: String + ): Boolean = withContext(Dispatchers.IO) { + if (!casLoginUrl.startsWith("https://one.ahu.edu.cn/cas/login", ignoreCase = true)) { + Log.w(TAG, "Rejected non-campus CAS refresh URL") + return@withContext false + } + + try { + val loginPage = JwxtApi.LOGIN_API.fetchUrl(casLoginUrl) + val pageFinalUrl = loginPage.raw().request.url.toString() + if (loginPage.code() == WEB_VERIFICATION_REQUIRED_CODE) { + loginPage.errorBody()?.close() + return@withContext false + } + if (!loginPage.isSuccessful) { + loginPage.errorBody()?.close() + return@withContext false + } + + if (!pageFinalUrl.contains("one.ahu.edu.cn/cas/login", ignoreCase = true)) { + loginPage.body()?.close() + return@withContext true + } + + val loginBody = loginPage.body() ?: return@withContext false + val document = Jsoup.parse(loginBody.use { it.string() }) + val loginTicket = document.selectFirst("input[name=lt]")?.attr("value") + ?.takeIf { it.isNotBlank() } + ?: return@withContext false + val execution = document.selectFirst("input[name=execution]")?.attr("value") + ?.takeIf { it.isNotBlank() } + ?: "e1s1" + val action = document.selectFirst("form#loginForm")?.attr("action") + ?.takeIf { it.isNotBlank() } + ?: return@withContext false + val loginPostUrl = resolveCasLoginAction(pageFinalUrl, action) + ?: return@withContext false + val cipher = DES().strEnc(username + password + loginTicket, "1", "2", "3") + + val deviceResponse = JwxtApi.LOGIN_API.device( + url = "https://one.ahu.edu.cn/cas/device", + username = username.length, + password = password.length, + rsa = cipher + ) + val deviceResponseText = deviceResponse.body()?.use { it.string() }.orEmpty() + deviceResponse.errorBody()?.close() + val deviceStatus = parseCasDeviceStatus(deviceResponseText) + val deviceReady = when (deviceStatus) { + "ok" -> true + "unbind" -> { + val confirmation = JwxtApi.LOGIN_API.confirmDeviceForSession( + url = "https://one.ahu.edu.cn/cas/device", + saveDevice = 0 + ) + val confirmationText = confirmation.body()?.use { it.string() }.orEmpty() + confirmation.errorBody()?.close() + confirmation.isSuccessful && parseCasDeviceStatus(confirmationText) == "ok" + } + else -> false + } + if (!deviceResponse.isSuccessful || !deviceReady) { + Log.w(TAG, "Central CAS device verification was rejected (status=$deviceStatus)") + return@withContext false + } + + val loginResponse = JwxtApi.LOGIN_API.login( + url = loginPostUrl, + rsa = cipher, + username = username.length, + password = password.length, + lt = loginTicket, + execution = execution + ) + val finalUrl = loginResponse.raw().request.url.toString() + val succeeded = loginResponse.isSuccessful && + !finalUrl.contains("one.ahu.edu.cn/cas/login", ignoreCase = true) + loginResponse.body()?.close() + loginResponse.errorBody()?.close() + if (succeeded) syncAndroidCookiesToRust() + succeeded + } catch (error: Exception) { + Log.w(TAG, "Central CAS refresh failed (${error.javaClass.simpleName})") + false + } + } + + private fun parseCasDeviceStatus(responseText: String): String? = runCatching { + @Suppress("UNCHECKED_CAST") + (Gson().fromJson(responseText, Map::class.java) as? Map) + ?.get("info") + ?.toString() + }.getOrNull() + + /** + * Android's CookieJar retains the effective host for host-only cookies. Exporting from it + * avoids the ambiguity of inferring domains from cookie names such as JSESSIONID. + */ + private suspend fun syncAndroidCookiesToRust() { + val cookiesJson = Gson().toJson( + com.ahu.ahutong.data.crawler.manager.CookieManager.cookieJar + .allCookies + .map { cookie -> + mapOf( + "name" to cookie.name, + "value" to cookie.value, + "domain" to cookie.domain, + "path" to cookie.path, + "secure" to cookie.secure, + "http_only" to cookie.httpOnly + ) + } + ) + AHUCache.saveRustCookies(cookiesJson) + + val localServiceImported = getHttpClient() + ?.init(cookiesJson) + ?.onFailure { Log.w(TAG, "Failed to sync Android session to local service", it) } + ?.isSuccess == true + if (!localServiceImported && RustSDK.isNativeLoaded()) { + RustSDK.initSafe(cookiesJson) + } + } + suspend fun importWebLoginCookies(cookiesJson: String): Result = withContext(Dispatchers.IO) { try { @@ -461,6 +606,7 @@ object AHURepository { Result.success(Unit) } catch (e: Throwable) { + if (e is CancellationException) throw e Log.w(TAG, "Failed to import WebView login cookies", e) Result.failure(e) } @@ -493,6 +639,7 @@ object AHURepository { AHUCache.saveRustCookies(cookies) Log.d(TAG, "Persisted Rust JNI cookies: ${cookies.length} bytes") } catch (t: Throwable) { + if (t is CancellationException) throw t Log.w(TAG, "Failed to persist Rust JNI cookies", t) } } @@ -550,6 +697,9 @@ object AHURepository { suspend fun getBathroomInfo(bathroom: String, tel: String): AHUResponse = withContext(Dispatchers.IO) { + if (!ensureYcardCredential()) { + return@withContext ycardCredentialNotReadyResponse() + } dataSource.getBathroomTelInfo(bathroom = bathroom, tel = tel) } @@ -660,6 +810,7 @@ object AHURepository { Result.failure(Throwable(response.msg)) } } catch (e: Throwable) { + if (e is CancellationException) throw e Result.failure(e) } } @@ -676,6 +827,7 @@ object AHURepository { Result.failure(Throwable(msg)) } } catch (e: Throwable) { + if (e is CancellationException) throw e Result.failure(e) } } @@ -685,3 +837,6 @@ object AHURepository { return take(2) + "***" + takeLast(2) } } + +internal fun resolveCasLoginAction(pageUrl: String, action: String): String? = + pageUrl.toHttpUrlOrNull()?.resolve(action)?.toString() diff --git a/app/src/main/java/com/ahu/ahutong/data/AHUResponse.java b/app/src/main/java/com/ahu/ahutong/data/AHUResponse.java index feeb02ee..2b4fadb8 100644 --- a/app/src/main/java/com/ahu/ahutong/data/AHUResponse.java +++ b/app/src/main/java/com/ahu/ahutong/data/AHUResponse.java @@ -9,8 +9,8 @@ */ public class AHUResponse { private T data; - private String msg; - private Integer code; + private String msg = ""; + private int code = -1; public T getData() { return data; @@ -28,11 +28,11 @@ public void setMsg(String msg) { this.msg = msg; } - public Integer getCode() { + public int getCode() { return code; } - public void setCode(Integer code) { + public void setCode(int code) { this.code = code; } diff --git a/app/src/main/java/com/ahu/ahutong/data/EvaluationRepository.kt b/app/src/main/java/com/ahu/ahutong/data/EvaluationRepository.kt index 481d4b56..5f0e125d 100644 --- a/app/src/main/java/com/ahu/ahutong/data/EvaluationRepository.kt +++ b/app/src/main/java/com/ahu/ahutong/data/EvaluationRepository.kt @@ -12,7 +12,9 @@ import com.ahu.ahutong.data.model.EvalQuestionnaire import com.ahu.ahutong.data.model.EvalSearchResult import com.ahu.ahutong.data.model.EvalSemester import com.ahu.ahutong.data.model.EvalSubmitRequest +import com.ahu.ahutong.data.model.EvalTask import com.ahu.ahutong.data.model.EvalTaskItem +import com.ahu.ahutong.data.model.EvalTeacher import com.google.gson.Gson import com.google.gson.JsonElement import com.google.gson.reflect.TypeToken @@ -35,7 +37,15 @@ object EvaluationRepository { private var currentSemesterId: String = "" suspend fun getSemesters(): Result> = runCatching { - requestWithSession { api.getSemesters() }.requireData() + requestWithSession { api.getSemesters() }.requireData().orEmpty().map { semester -> + semester.copy( + id = semester.id.orEmpty(), + nameZh = semester.nameZh.orEmpty(), + nameEn = semester.nameEn.orEmpty(), + code = semester.code.orEmpty(), + schoolYear = semester.schoolYear.orEmpty() + ) + } } fun getCurrentSemesterId(): String = currentSemesterId @@ -52,7 +62,7 @@ object EvaluationRepository { semesterId = semesterId, evaluated = evaluated ) - }.requireData().items + }.requireData().items.orEmpty().map(EvalTaskItem::sanitized) } suspend fun getQuestions(questionnaireId: String): Result = runCatching { @@ -60,8 +70,19 @@ object EvaluationRepository { api.getQuestionnaire(questionnaireId) }.requireData() val type = object : TypeToken>() {}.type - val questions = gson.fromJson>(questionnaire.questions, type).orEmpty() - EvalQuestionnaireForm(questionnaire, questions) + val sanitizedQuestionnaire = questionnaire.copy( + id = questionnaire.id.orEmpty(), + nameZh = questionnaire.nameZh.orEmpty(), + questions = questionnaire.questions.orEmpty().ifBlank { "[]" }, + questionNum = questionnaire.questionNum.orEmpty(), + evaluateTypeId = questionnaire.evaluateTypeId.orEmpty(), + name = questionnaire.name.orEmpty() + ) + val questions = gson.fromJson>( + sanitizedQuestionnaire.questions, + type + ).orEmpty() + EvalQuestionnaireForm(sanitizedQuestionnaire, questions) } suspend fun checkParam(stdSumTaskId: String): Result = runCatching { @@ -70,13 +91,13 @@ object EvaluationRepository { suspend fun checkSubmit(request: EvalSubmitRequest): Result = runCatching { val response = requestWithSession { api.checkSubmit(request) } - check(response.code == 0) { response.msg.ifBlank { "提交检查失败" } } + check(response.code == 0) { response.msg.orEmpty().ifBlank { "提交检查失败" } } response.data.orEmpty() } suspend fun submit(request: EvalSubmitRequest): Result = runCatching { val response = requestWithSession { api.submit(request) } - check(response.code == 0) { response.msg.ifBlank { "提交失败" } } + check(response.code == 0) { response.msg.orEmpty().ifBlank { "提交失败" } } } private suspend fun requestWithSession( @@ -94,6 +115,11 @@ object EvaluationRepository { } if (first.code == 0) return first + // Business validation errors are final responses, not evidence of an expired login. + // Retrying every non-zero response forced a complete token bootstrap and made the + // evaluation page look broken or extremely slow. + if (!first.indicatesExpiredSession()) return first + ensureToken(forceRefresh = true) return callEvaluationApi("评教接口请求失败") { block() } } @@ -122,7 +148,7 @@ object EvaluationRepository { api.tokenRenew(mapOf("token" to seedToken)) } check(response.code == 0 && response.data?.token?.isNotBlank() == true) { - response.msg.ifBlank { "评教 token 续期失败" } + response.msg.orEmpty().ifBlank { "评教 token 续期失败" } } val renewed = response.data!!.token EvaluationApi.setAuthorizationToken(renewed) @@ -131,7 +157,7 @@ object EvaluationRepository { val account = callEvaluationApi("评教身份初始化失败") { api.getAccount(renewed) } - check(account.code == 0) { account.msg.ifBlank { "评教身份初始化失败" } } + check(account.code == 0) { account.msg.orEmpty().ifBlank { "评教身份初始化失败" } } currentSemesterId = account.data?.currentSemesterId.orEmpty() val identity = account.data?.currentIdentity ?.takeIf { it.isNotBlank() } @@ -140,10 +166,10 @@ object EvaluationRepository { val currentYear = callEvaluationApi("评教学年初始化失败") { api.getCurrentYear(renewed) } - check(currentYear.code == 0) { currentYear.msg.ifBlank { "评教学年初始化失败" } } + check(currentYear.code == 0) { currentYear.msg.orEmpty().ifBlank { "评教学年初始化失败" } } Log.i(TAG, "eval current year initialized") val menu = getHomeMenuWithCookieRetry(identity) - check(menu.code == 0) { menu.msg.ifBlank { "评教菜单初始化失败" } } + check(menu.code == 0) { menu.msg.orEmpty().ifBlank { "评教菜单初始化失败" } } Log.i(TAG, "eval menu initialized") token = renewed AHUCache.saveEvalToken(renewed) @@ -264,10 +290,20 @@ object EvaluationRepository { } private fun EvalApiResponse.requireData(): T { - check(code == 0 && data != null) { msg.ifBlank { "评教接口返回异常" } } + check(code == 0 && data != null) { msg.orEmpty().ifBlank { "评教接口返回异常" } } return data } + private fun EvalApiResponse<*>.indicatesExpiredSession(): Boolean { + val normalized = msg.orEmpty().lowercase() + return code == 401 || + normalized.contains("token") || + normalized.contains("unauthorized") || + normalized.contains("未登录") || + normalized.contains("登录失效") || + normalized.contains("登录过期") + } + private suspend fun callEvaluationApi( stage: String, block: suspend () -> EvalApiResponse @@ -284,6 +320,35 @@ object EvaluationRepository { ) } +/** Gson can still assign JSON null to Kotlin non-null properties; normalize at the API edge. */ +private fun EvalTaskItem.sanitized(): EvalTaskItem = copy( + lessonId = lessonId.orEmpty(), + studentId = studentId.orEmpty(), + courseName = courseName.orEmpty(), + lessonCode = lessonCode.orEmpty(), + lessonNameZh = lessonNameZh.orEmpty(), + taskList = taskList.orEmpty().map(EvalTask::sanitized) +) + +private fun EvalTask.sanitized(): EvalTask = copy( + stdSumEvaBatchId = stdSumEvaBatchId.orEmpty(), + evaluationQuestionnaireId = evaluationQuestionnaireId.orEmpty(), + evaluationQuestionnaireName = evaluationQuestionnaireName.orEmpty(), + teachers = teachers.orEmpty().map(EvalTeacher::sanitized), + days = days.orEmpty(), + stdSumTaskId = stdSumTaskId.orEmpty() +) + +private fun EvalTeacher.sanitized(): EvalTeacher = copy( + stdSumTaskId = stdSumTaskId.orEmpty(), + teacherId = teacherId.orEmpty(), + personId = personId.orEmpty(), + role = role.orEmpty(), + teacherName = teacherName.orEmpty(), + status = status.orEmpty(), + code = code.orEmpty() +) + data class EvalQuestionnaireForm( val questionnaire: EvalQuestionnaire, val questions: List diff --git a/app/src/main/java/com/ahu/ahutong/data/crawler/CrawlerDataSource.kt b/app/src/main/java/com/ahu/ahutong/data/crawler/CrawlerDataSource.kt index 12c42862..ccb4e7c0 100644 --- a/app/src/main/java/com/ahu/ahutong/data/crawler/CrawlerDataSource.kt +++ b/app/src/main/java/com/ahu/ahutong/data/crawler/CrawlerDataSource.kt @@ -473,8 +473,12 @@ class CrawlerDataSource : BaseDataSource { return result } - override suspend fun getBathRooms(): AHUResponse> { - return AHUResponse>() + override suspend fun getBathRooms(): AHUResponse> { + return AHUResponse>().apply { + code = -1 + msg = "浴室开放状态服务暂不可用" + data = emptyList() + } } override suspend fun getExamInfo( @@ -657,7 +661,7 @@ class CrawlerDataSource : BaseDataSource { .build() - val res = YcardApi.API.getFeeItemThirdData(formBody) + val res = YcardApi.authorizedCall { getFeeItemThirdData(formBody) } if (res.isSuccessful) { val responseBody = res.body() @@ -682,27 +686,34 @@ class CrawlerDataSource : BaseDataSource { return response } - override suspend fun getCardInfo(): AHUResponse { - - val response = AHUResponse() - - response.data = YcardApi.API.loadCardRecharge() - response.code = 0 - - return response - } + override suspend fun getCardInfo(): AHUResponse { + val response = AHUResponse() + val result = YcardApi.authorizedCall { loadCardRecharge() } + val body = result.body() + if (result.isSuccessful && body != null) { + response.data = body + response.code = 0 + response.msg = "success" + } else { + response.code = result.code().takeIf { it != 0 } ?: -1 + response.msg = "校园卡信息加载失败:${result.message()}" + } + return response + } override suspend fun getOrderThirdData(request: RequestBody): AHUResponse> { val response = AHUResponse>() - response.data = YcardApi.API.getOrderThirdData(request.toFormBody()) - response.code = 0; + response.data = YcardApi.authorizedCall { getOrderThirdData(request.toFormBody()) } + response.code = if (response.data?.isSuccessful == true) 0 else -1 + response.msg = response.data?.message().orEmpty() return response } override suspend fun pay(request: RequestBody): AHUResponse> { val response = AHUResponse>() - response.data = YcardApi.API.pay(request.toFormBody()) - response.code = 0; + response.data = YcardApi.authorizedCall { pay(request.toFormBody()) } + response.code = if (response.data?.isSuccessful == true) 0 else -1 + response.msg = response.data?.message().orEmpty() return response } diff --git a/app/src/main/java/com/ahu/ahutong/data/crawler/SdkDataSource.kt b/app/src/main/java/com/ahu/ahutong/data/crawler/SdkDataSource.kt index 14a55c0f..2ee8bf25 100644 --- a/app/src/main/java/com/ahu/ahutong/data/crawler/SdkDataSource.kt +++ b/app/src/main/java/com/ahu/ahutong/data/crawler/SdkDataSource.kt @@ -496,10 +496,22 @@ class SdkDataSource : BaseDataSource { } override suspend fun getBathRooms(): AHUResponse> { - return AHUResponse>() + return crawlerFallback.getBathRooms() } override suspend fun getExamInfo(studentID: String, studentName: String): AHUResponse> { + // The Android crawler understands the current server-rendered exam page. The bundled + // service can still expose the legacy payload shape and currently spends several seconds + // before reporting that it cannot parse it, so use it only as a recovery path. + val crawlerResult = crawlerFallback.getExamInfo(studentID, studentName) + if (crawlerResult.code == 0) { + return crawlerResult + } + + Log.w( + "LocalServiceClient", + "[getExamInfo] Android crawler failed, trying local service: ${crawlerResult.msg}" + ) val response = AHUResponse>() try { val httpClient = getHttpClient() @@ -514,12 +526,15 @@ class SdkDataSource : BaseDataSource { response.code = 0 response.data = result.getOrNull() } else { - Log.w("LocalServiceClient", "[getExamInfo] Rust failed, fallback to Android crawler: ${result.exceptionOrNull()?.message}") - return crawlerFallback.getExamInfo(studentID, studentName) + Log.w( + "LocalServiceClient", + "[getExamInfo] Local service recovery failed: ${result.exceptionOrNull()?.message}" + ) + return crawlerResult } } catch (e: Exception) { - Log.w("LocalServiceClient", "[getExamInfo] Rust threw, fallback to Android crawler", e) - return crawlerFallback.getExamInfo(studentID, studentName) + Log.w("LocalServiceClient", "[getExamInfo] Local service recovery threw", e) + return crawlerResult } return response } @@ -559,7 +574,7 @@ class SdkDataSource : BaseDataSource { .build() - val res = YcardApi.API.getFeeItemThirdData(formBody) + val res = YcardApi.authorizedCall { getFeeItemThirdData(formBody) } if (res.isSuccessful) { val responseBody = res.body() @@ -585,26 +600,33 @@ class SdkDataSource : BaseDataSource { } override suspend fun getCardInfo(): AHUResponse { - val response = AHUResponse() - - response.data = YcardApi.API.loadCardRecharge() - response.code = 0 - + val result = YcardApi.authorizedCall { loadCardRecharge() } + val body = result.body() + if (result.isSuccessful && body != null) { + response.data = body + response.code = 0 + response.msg = "success" + } else { + response.code = result.code().takeIf { it != 0 } ?: -1 + response.msg = "校园卡信息加载失败:${result.message()}" + } return response } override suspend fun getOrderThirdData(request : RequestBody): AHUResponse> { val response = AHUResponse>() - response.data = YcardApi.API.getOrderThirdData(request.toFormBody()) - response.code = 0; + response.data = YcardApi.authorizedCall { getOrderThirdData(request.toFormBody()) } + response.code = if (response.data?.isSuccessful == true) 0 else -1 + response.msg = response.data?.message().orEmpty() return response } override suspend fun pay(request: RequestBody): AHUResponse> { val response = AHUResponse>() - response.data = YcardApi.API.pay(request.toFormBody()) - response.code = 0; + response.data = YcardApi.authorizedCall { pay(request.toFormBody()) } + response.code = if (response.data?.isSuccessful == true) 0 else -1 + response.msg = response.data?.message().orEmpty() return response } diff --git a/app/src/main/java/com/ahu/ahutong/data/crawler/api/adwmh/AdwmhApi.kt b/app/src/main/java/com/ahu/ahutong/data/crawler/api/adwmh/AdwmhApi.kt index 80967dd9..9d19eada 100644 --- a/app/src/main/java/com/ahu/ahutong/data/crawler/api/adwmh/AdwmhApi.kt +++ b/app/src/main/java/com/ahu/ahutong/data/crawler/api/adwmh/AdwmhApi.kt @@ -1,18 +1,19 @@ -package com.ahu.ahutong.data.crawler.api.adwmh - +package com.ahu.ahutong.data.crawler.api.adwmh + +import com.ahu.ahutong.BuildConfig import com.ahu.ahutong.data.AHUResponse import com.ahu.ahutong.data.crawler.manager.CookieManager import com.ahu.ahutong.data.crawler.model.adwnh.AllCampus import com.ahu.ahutong.data.crawler.model.adwnh.AllLostFoundType import com.ahu.ahutong.data.crawler.model.adwnh.Balance import com.ahu.ahutong.data.crawler.model.adwnh.Captcha -import com.ahu.ahutong.data.crawler.model.adwnh.Info import com.ahu.ahutong.data.crawler.model.adwnh.LostFoundPublishRequest import com.ahu.ahutong.data.crawler.model.adwnh.LostFoundResponse import com.ahu.ahutong.data.crawler.model.adwnh.QRcode import com.ahu.ahutong.data.crawler.net.AutoLoginInterceptor import com.ahu.ahutong.data.crawler.net.TokenAuthenticator -import okhttp3.MultipartBody +import okhttp3.MultipartBody +import okhttp3.Authenticator import okhttp3.OkHttpClient import okhttp3.ResponseBody import okhttp3.logging.HttpLoggingInterceptor @@ -34,12 +35,12 @@ interface AdwmhApi { @POST("/user/login") @FormUrlEncoded - suspend fun loginWithCaptcha( - @Field("username") username: String, - @Field("pwd") password: String, - @Field("flag") flag: Int, - @Field("imgcode") imgcode: String - ): Info + suspend fun loginWithCaptcha( + @Field("username") username: String, + @Field("pwd") password: String, + @Field("flag") flag: Int, + @Field("imgcode") imgcode: String + ): ResponseBody @GET("/xzxcard/yue") @@ -82,6 +83,7 @@ interface AdwmhApi { companion object { val loggingInterceptor = HttpLoggingInterceptor().apply { redactHeader("Authorization") + redactHeader("Synjones-Auth") redactHeader("Cookie") redactHeader("Set-Cookie") level = HttpLoggingInterceptor.Level.HEADERS @@ -98,7 +100,7 @@ interface AdwmhApi { val BASE_URL = "https://adwmh.ahu.edu.cn/" - val okHttpClient = OkHttpClient + val okHttpClient = OkHttpClient .Builder() .addNetworkInterceptor { chain -> val request = chain.request().newBuilder() @@ -111,14 +113,29 @@ interface AdwmhApi { .followRedirects(true) .followSslRedirects(true) .cookieJar(cookieJar) - .addNetworkInterceptor(loggingInterceptor) - .build() - - val API = Retrofit.Builder() + .apply { + if (BuildConfig.DEBUG) addNetworkInterceptor(loggingInterceptor) + } + .build() + + private val loginOkHttpClient = okHttpClient.newBuilder() + .authenticator(Authenticator.NONE) + .apply { + networkInterceptors().removeAll { it is AutoLoginInterceptor } + } + .build() + + val API = Retrofit.Builder() .addConverterFactory(GsonConverterFactory.create()) .client(okHttpClient) .baseUrl(BASE_URL) - .build().create(AdwmhApi::class.java) + .build().create(AdwmhApi::class.java) + + val LOGIN_API = Retrofit.Builder() + .addConverterFactory(GsonConverterFactory.create()) + .client(loginOkHttpClient) + .baseUrl(BASE_URL) + .build().create(AdwmhApi::class.java) } } diff --git a/app/src/main/java/com/ahu/ahutong/data/crawler/api/jwxt/EvaluationApi.kt b/app/src/main/java/com/ahu/ahutong/data/crawler/api/jwxt/EvaluationApi.kt index 4c796262..f2f808c3 100644 --- a/app/src/main/java/com/ahu/ahutong/data/crawler/api/jwxt/EvaluationApi.kt +++ b/app/src/main/java/com/ahu/ahutong/data/crawler/api/jwxt/EvaluationApi.kt @@ -100,10 +100,9 @@ interface EvaluationApi { if (response.code == 401 && response.request.url.encodedPath .startsWith("/eams5-evaluation-service/") ) { - val body = response.peekBody(4096).string() Log.w( TAG, - "401 ${redactUrl(response.request.url.toString())} body=${body.take(4096)}" + "401 ${redactUrl(response.request.url.toString())}; response body suppressed" ) } response diff --git a/app/src/main/java/com/ahu/ahutong/data/crawler/api/jwxt/JwxtApi.kt b/app/src/main/java/com/ahu/ahutong/data/crawler/api/jwxt/JwxtApi.kt index 8f7b9f85..55a6234f 100644 --- a/app/src/main/java/com/ahu/ahutong/data/crawler/api/jwxt/JwxtApi.kt +++ b/app/src/main/java/com/ahu/ahutong/data/crawler/api/jwxt/JwxtApi.kt @@ -1,5 +1,6 @@ package com.ahu.ahutong.data.crawler.api.jwxt +import com.ahu.ahutong.BuildConfig import com.ahu.ahutong.data.crawler.manager.CookieManager import com.ahu.ahutong.data.crawler.model.jwxt.CourseTable import com.ahu.ahutong.data.crawler.model.jwxt.CurrentTeachWeek @@ -11,6 +12,7 @@ import com.ahu.ahutong.data.crawler.model.jwxt.GradeResponse import com.ahu.ahutong.data.crawler.net.AutoLoginInterceptor import com.ahu.ahutong.data.crawler.net.TokenAuthenticator import okhttp3.OkHttpClient +import okhttp3.Authenticator import okhttp3.ResponseBody import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Response @@ -30,6 +32,9 @@ interface JwxtApi { @GET("/student/sso/login") suspend fun fetchLoginInfo(): Response + @GET + suspend fun fetchUrl(@Url url: String): Response + @GET("/student/for-std/course-table/semester/{id}/print-data") suspend fun getCourse( @Path("id") semesterPathId: Int, @@ -72,6 +77,14 @@ interface JwxtApi { @Field("method") method: String = "login" ): Response + @FormUrlEncoded + @POST + suspend fun confirmDeviceForSession( + @Url url: String, + @Field("saveDevice") saveDevice: Int = 0, + @Field("method") method: String = "bind2" + ): Response + @FormUrlEncoded @POST suspend fun login( @@ -105,6 +118,10 @@ interface JwxtApi { "(KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36" val loggingInterceptor = HttpLoggingInterceptor().apply { + redactHeader("Authorization") + redactHeader("Synjones-Auth") + redactHeader("Cookie") + redactHeader("Set-Cookie") level = HttpLoggingInterceptor.Level.HEADERS } @@ -124,10 +141,19 @@ interface JwxtApi { .authenticator(TokenAuthenticator()) .followRedirects(true) .followSslRedirects(true) - .addNetworkInterceptor(loggingInterceptor) .connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS) .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) .writeTimeout(15, java.util.concurrent.TimeUnit.SECONDS) + .apply { + if (BuildConfig.DEBUG) addNetworkInterceptor(loggingInterceptor) + } + .build() + + private val loginOkHttpClient = okHttpClient.newBuilder() + .authenticator(Authenticator.NONE) + .apply { + networkInterceptors().removeAll { it is AutoLoginInterceptor } + } .build() @@ -136,5 +162,11 @@ interface JwxtApi { .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .build().create(JwxtApi::class.java) + + val LOGIN_API = Retrofit.Builder() + .baseUrl(BASE_URL) + .client(loginOkHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build().create(JwxtApi::class.java) } } diff --git a/app/src/main/java/com/ahu/ahutong/data/crawler/api/ycard/YcardApi.kt b/app/src/main/java/com/ahu/ahutong/data/crawler/api/ycard/YcardApi.kt index 3f8e2b73..aaea0e9d 100644 --- a/app/src/main/java/com/ahu/ahutong/data/crawler/api/ycard/YcardApi.kt +++ b/app/src/main/java/com/ahu/ahutong/data/crawler/api/ycard/YcardApi.kt @@ -1,13 +1,16 @@ -package com.ahu.ahutong.data.crawler.api.ycard - +package com.ahu.ahutong.data.crawler.api.ycard + +import com.ahu.ahutong.BuildConfig import com.ahu.ahutong.data.crawler.manager.CookieManager import com.ahu.ahutong.data.crawler.manager.TokenManager import com.ahu.ahutong.data.crawler.model.ycard.CardInfo import com.ahu.ahutong.data.crawler.model.ycard.Token import okhttp3.Interceptor import okhttp3.OkHttpClient -import okhttp3.RequestBody -import okhttp3.ResponseBody +import okhttp3.RequestBody +import okhttp3.ResponseBody +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Call import retrofit2.Response @@ -34,7 +37,7 @@ interface YcardApi { suspend fun loadCardRecharge( @Query("scene") scene: String = "cardRecharge", @Query("synAccessSource") synAccessSource: String = "h5", - ): CardInfo + ): Response @GET("/charge/feeitem/toAppitem") suspend fun enterFeeItem( @@ -91,13 +94,18 @@ interface YcardApi { username=&password=&grant_type=password&scope=all&loginFrom=h5&logintype=sso&device_token=h5&synAccessSource=h5 * */ - companion object { + companion object { - private val BASE_URL = "https://ycard.ahu.edu.cn/" + internal const val BASE_URL = "https://ycard.ahu.edu.cn/" + internal const val LOGIN_TARGET_URL = "https://ycard.ahu.edu.cn/plat/?name=loginTransit" - private val loggingInterceptor = HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.HEADERS + private val loggingInterceptor = HttpLoggingInterceptor().apply { + redactHeader("Authorization") + redactHeader("Synjones-Auth") + redactHeader("Cookie") + redactHeader("Set-Cookie") + level = HttpLoggingInterceptor.Level.HEADERS } private val cookieJar = CookieManager.cookieJar @@ -121,22 +129,63 @@ interface YcardApi { chain.proceed(newRequest) } - val okHttpClient = OkHttpClient.Builder() + val okHttpClient = OkHttpClient.Builder() .cookieJar(cookieJar) .followRedirects(true) .followSslRedirects(true) .addInterceptor(interceptor = authInterceptor) - .addInterceptor(loggingInterceptor) - .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) - .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) - .writeTimeout(10, java.util.concurrent.TimeUnit.SECONDS) - .build() - - val API = Retrofit.Builder() + .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .writeTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .apply { + if (BuildConfig.DEBUG) addInterceptor(loggingInterceptor) + } + .build() + + /** + * The SSO bootstrap must stop as soon as CAS emits a service ticket. Following that + * redirect all the way into loginTransit can cycle back through neusoftCas before the + * caller has extracted the one-shot ticket. + */ + internal val loginRedirectClient = OkHttpClient.Builder() + .cookieJar(cookieJar) + .followRedirects(false) + .followSslRedirects(false) + .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .writeTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .build() + + val API = Retrofit.Builder() .baseUrl(BASE_URL) .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) - .build().create(YcardApi::class.java) - - } + .build().create(YcardApi::class.java) + + /** + * Runs an authenticated campus-card request and retries once when its token has + * expired. Keeping the refresh at the suspending call site avoids blocking OkHttp's + * interceptor threads and coalesces concurrent refreshes in [TokenManager]. + */ + suspend fun authorizedCall( + request: suspend YcardApi.() -> Response + ): Response { + val attemptedToken = TokenManager.awaitToken() + if (attemptedToken.isNullOrBlank()) { + return Response.error( + 401, + "校园卡登录凭证不可用".toResponseBody("text/plain".toMediaType()) + ) + } + val firstResponse = API.request() + if (firstResponse.code() != 401) return firstResponse + + firstResponse.errorBody()?.close() + if (TokenManager.refreshAfterUnauthorized(attemptedToken).isNullOrBlank()) { + return firstResponse + } + return API.request() + } + + } } diff --git a/app/src/main/java/com/ahu/ahutong/data/crawler/manager/CookieManager.kt b/app/src/main/java/com/ahu/ahutong/data/crawler/manager/CookieManager.kt index 4e188eb4..36a12ece 100644 --- a/app/src/main/java/com/ahu/ahutong/data/crawler/manager/CookieManager.kt +++ b/app/src/main/java/com/ahu/ahutong/data/crawler/manager/CookieManager.kt @@ -1,13 +1,19 @@ package com.ahu.ahutong.data.crawler.manager -import com.ahu.ahutong.AHUApplication -import com.ahu.ahutong.data.api.AHUCookieJar -import com.franmontiel.persistentcookiejar.cache.SetCookieCache -import com.franmontiel.persistentcookiejar.persistence.SharedPrefsCookiePersistor +import com.ahu.ahutong.AHUApplication +import com.ahu.ahutong.data.api.AHUCookieJar +import com.franmontiel.persistentcookiejar.cache.SetCookieCache +import com.franmontiel.persistentcookiejar.persistence.SharedPrefsCookiePersistor -object CookieManager { - - - val cookieJar = AHUCookieJar(SetCookieCache(), SharedPrefsCookiePersistor(AHUApplication.getApp())) - -} \ No newline at end of file +object CookieManager { + private val encryptedPersistor = EncryptedCookiePersistor().also { encrypted -> + // One-time, destructive migration: plaintext cookies must not remain on disk. + val legacy = SharedPrefsCookiePersistor(AHUApplication.getApp()) + val legacyCookies = legacy.loadAll() + if (legacyCookies.isNotEmpty()) encrypted.saveAll(legacyCookies) + legacy.clear() + } + + val cookieJar = AHUCookieJar(SetCookieCache(), encryptedPersistor) + +} diff --git a/app/src/main/java/com/ahu/ahutong/data/crawler/manager/EncryptedCookiePersistor.kt b/app/src/main/java/com/ahu/ahutong/data/crawler/manager/EncryptedCookiePersistor.kt new file mode 100644 index 00000000..423a55fc --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/data/crawler/manager/EncryptedCookiePersistor.kt @@ -0,0 +1,49 @@ +package com.ahu.ahutong.data.crawler.manager + +import com.ahu.ahutong.data.security.SecureStorage +import com.franmontiel.persistentcookiejar.persistence.CookiePersistor +import com.franmontiel.persistentcookiejar.persistence.SerializableCookie +import java.security.MessageDigest +import okhttp3.Cookie + +/** Persists cookie payloads as AES-GCM ciphertext instead of plaintext preferences. */ +class EncryptedCookiePersistor : CookiePersistor { + override fun loadAll(): List = + SecureStorage.entries(COOKIE_PREFIX).values.mapNotNull { encoded -> + runCatching { SerializableCookie().decode(encoded) }.getOrNull() + } + + override fun saveAll(cookies: Collection) { + cookies.forEach { cookie -> + val encoded = SerializableCookie().encode(cookie) ?: return@forEach + SecureStorage.putString(storageKey(cookie), encoded) + } + } + + override fun removeAll(cookies: Collection) { + cookies.forEach { SecureStorage.remove(storageKey(it)) } + } + + override fun clear() { + SecureStorage.clearPrefix(COOKIE_PREFIX) + } + + private fun storageKey(cookie: Cookie): String { + val identity = buildString { + append(if (cookie.secure) "https" else "http") + append("://") + append(cookie.domain) + append(cookie.path) + append('|') + append(cookie.name) + } + val digest = MessageDigest.getInstance("SHA-256") + .digest(identity.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + return COOKIE_PREFIX + digest + } + + private companion object { + const val COOKIE_PREFIX = "cookies." + } +} diff --git a/app/src/main/java/com/ahu/ahutong/data/crawler/manager/TokenManager.kt b/app/src/main/java/com/ahu/ahutong/data/crawler/manager/TokenManager.kt index e1b5f4de..b73ccdd2 100644 --- a/app/src/main/java/com/ahu/ahutong/data/crawler/manager/TokenManager.kt +++ b/app/src/main/java/com/ahu/ahutong/data/crawler/manager/TokenManager.kt @@ -1,35 +1,41 @@ package com.ahu.ahutong.data.crawler.manager import android.util.Log +import com.ahu.ahutong.data.AHURepository import com.ahu.ahutong.data.crawler.api.ycard.YcardApi -import okhttp3.Response +import com.ahu.ahutong.data.crawler.net.SessionRefreshCoordinator +import com.ahu.ahutong.data.dao.AHUCache +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.withContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.net.URLDecoder object TokenManager { val TAG = "TokenManager" - private var token :String? = null - - - @Synchronized - fun getToken():String?{ - if (!token.isNullOrBlank()) return token + @Volatile + private var token: String? = null + private val refreshMutex = Mutex() - Log.e(TAG, "getToken: token is null", ) - - try { + /** Returns the current in-memory snapshot and never performs network I/O. */ + fun getToken(): String? = token - val loginResponse = YcardApi.API.login().execute() //假设已经登陆过one.ahu.ehu.cn - val redirectUrl = extractRedirectLocation(loginResponse) - ?: loginResponse.raw().request.url.toString() + private fun fetchToken(): TokenFetchResult { + return try { + val loginResult = probeCampusCardLogin() + if (loginResult.ticketUrl == null) { + return TokenFetchResult( + requiresSessionRefresh = loginResult.casLoginUrl != null, + casLoginUrl = loginResult.casLoginUrl + ) + } - val regex = Regex("[?&]ticket=([^&]+)") - val match = regex.find(redirectUrl) - val ticket = match?.groupValues?.get(1) ?: return null + val ticket = extractCampusCardCredential(loginResult.ticketUrl) + ?: return TokenFetchResult() val decodedUsername = URLDecoder.decode(URLDecoder.decode(ticket, "UTF-8"), "UTF-8") val tokenResponse = YcardApi.API.getToken( @@ -37,54 +43,151 @@ object TokenManager { password = decodedUsername ).execute() - if (tokenResponse.isSuccessful) { - token = tokenResponse.body()?.access_token + if (tokenResponse.isSuccessful) { + val refreshed = tokenResponse.body()?.access_token Log.i(TAG, "getToken: token acquired") - return token + TokenFetchResult(token = refreshed) + } else { + Log.w(TAG, "getToken: credential exchange failed (${tokenResponse.code()})") + TokenFetchResult() } } catch (e: Exception) { Log.e(TAG, "getToken: request failed (${e.javaClass.simpleName})") + TokenFetchResult() } - return null } - suspend fun awaitToken( - timeoutMillis: Long = 8_000L, - retryDelayMillis: Long = 500L - ): String? { - val deadline = System.currentTimeMillis() + timeoutMillis + /** + * Walk the campus-card SSO redirects ourselves so the one-shot CAS ticket is captured + * before loginTransit has a chance to send the request back to the SSO entry point. + */ + private fun probeCampusCardLogin(): CampusCardLoginProbe { + var currentUrl = YcardApi.BASE_URL.toHttpUrl().newBuilder() + .addPathSegments("berserker-auth/cas/redirect/neusoftCas") + .addQueryParameter("targetUrl", YcardApi.LOGIN_TARGET_URL) + .build() + var casLoginUrl: String? = null - while (System.currentTimeMillis() <= deadline) { - val currentToken = withContext(Dispatchers.IO) { - getToken() - } - if (!currentToken.isNullOrBlank()) { - return currentToken + repeat(MAX_LOGIN_REDIRECTS) { + val request = Request.Builder().url(currentUrl).get().build() + YcardApi.loginRedirectClient.newCall(request).execute().use { response -> + val responseUrl = response.request.url + if (hasCampusCardCredential(responseUrl.toString())) { + return CampusCardLoginProbe(ticketUrl = responseUrl.toString()) + } + if (isCasLoginUrl(responseUrl.toString())) { + casLoginUrl = responseUrl.toString() + } + + val nextUrl = response.header("Location") + ?.let(responseUrl::resolve) + ?: return CampusCardLoginProbe(casLoginUrl = casLoginUrl) + if (hasCampusCardCredential(nextUrl.toString())) { + return CampusCardLoginProbe(ticketUrl = nextUrl.toString()) + } + if (isCasLoginUrl(nextUrl.toString())) { + casLoginUrl = nextUrl.toString() + } + currentUrl = nextUrl } - delay(retryDelayMillis) } + return CampusCardLoginProbe(casLoginUrl = casLoginUrl) + } - return withContext(Dispatchers.IO) { - getToken() + suspend fun awaitToken(): String? { + token?.takeIf { it.isNotBlank() }?.let { return it } + return refreshMutex.withLock { + token?.takeIf { it.isNotBlank() }?.let { return@withLock it } + fetchUsableToken() } } - private fun extractRedirectLocation(response: retrofit2.Response<*>): String? { - var current: Response? = response.raw().priorResponse - while (current != null) { - current.header("Location")?.let { location -> - if ("ticket=" in location) { - return location - } - } - current = current.priorResponse + /** + * Refreshes a token rejected by the server. Concurrent 401 responses share the same + * refresh; a request that arrives after another request has refreshed simply reuses it. + */ + suspend fun refreshAfterUnauthorized(rejectedToken: String?): String? = + refreshMutex.withLock { + token?.takeIf { current -> + current.isNotBlank() && rejectedToken != null && current != rejectedToken + }?.let { return@withLock it } + + token = null + fetchUsableToken() + } + + private suspend fun fetchUsableToken(): String? { + val observedGeneration = SessionRefreshCoordinator.currentGeneration() + val firstAttempt = withContext(Dispatchers.IO) { fetchToken() } + val result = if (firstAttempt.requiresSessionRefresh && + refreshStoredSession(observedGeneration, firstAttempt.casLoginUrl) + ) { + withContext(Dispatchers.IO) { fetchToken() } + } else { + firstAttempt } - return response.raw().header("Location") + return result.token?.takeIf { it.isNotBlank() }?.also { token = it } } - fun clear(){ + private suspend fun refreshStoredSession( + observedGeneration: Long, + casLoginUrl: String? + ): Boolean = + SessionRefreshCoordinator.refreshIfNeeded(observedGeneration) { + val user = AHUCache.getCurrentUser() ?: return@refreshIfNeeded false + val password = AHUCache.getWisdomPassword()?.takeIf { it.isNotBlank() } + ?: return@refreshIfNeeded false + + val serviceLoginUrl = casLoginUrl ?: return@refreshIfNeeded false + + Log.i(TAG, "Refreshing central CAS session for campus-card token") + AHURepository.refreshCentralCasSession( + username = user.xh.toString(), + password = password, + casLoginUrl = serviceLoginUrl + ) + } + + private fun isCasLoginUrl(url: String): Boolean = + url.contains("one.ahu.edu.cn/cas/login", ignoreCase = true) + + /** + * The central CAS `ST-*` is a one-shot service ticket that must be followed back into + * ycard. It is not the encoded campus-card credential accepted by the OAuth endpoint. + */ + private fun hasCampusCardCredential(url: String): Boolean = + extractCampusCardCredential(url) != null + + private fun extractCampusCardCredential(url: String): String? { + val rawTicket = Regex("[?&]ticket=([^&]+)") + .find(url) + ?.groupValues + ?.getOrNull(1) + ?: return null + val decodedOnce = runCatching { URLDecoder.decode(rawTicket, "UTF-8") } + .getOrDefault(rawTicket) + return rawTicket.takeUnless { + decodedOnce.startsWith("ST-", ignoreCase = true) || + decodedOnce.startsWith("PT-", ignoreCase = true) + } + } + + fun clear() { Log.e(TAG, "clear: Token", ) token = null - } - + } + + private data class TokenFetchResult( + val token: String? = null, + val requiresSessionRefresh: Boolean = false, + val casLoginUrl: String? = null + ) + + private data class CampusCardLoginProbe( + val ticketUrl: String? = null, + val casLoginUrl: String? = null + ) + + private const val MAX_LOGIN_REDIRECTS = 12 + } diff --git a/app/src/main/java/com/ahu/ahutong/data/crawler/model/ycard/CardPayRequest.kt b/app/src/main/java/com/ahu/ahutong/data/crawler/model/ycard/CardPayRequest.kt index 53de5963..d6a24743 100644 --- a/app/src/main/java/com/ahu/ahutong/data/crawler/model/ycard/CardPayRequest.kt +++ b/app/src/main/java/com/ahu/ahutong/data/crawler/model/ycard/CardPayRequest.kt @@ -1,37 +1,42 @@ -package com.ahu.ahutong.data.crawler.model.ycard - -import com.ahu.ahutong.data.crawler.utils.generateNonce -import com.ahu.ahutong.data.crawler.utils.getTimestamp -import com.ahu.ahutong.data.crawler.utils.sha256 - -class CardPayRequest(orderId: String) : RequestBody() { - - init { - val time = getTimestamp() - val nonce = generateNonce() - val appId = "56321" - val payStep = "2" - val payType = "BANKCARD" - val payTypeId = "63" - val redirectUrl = "https://ycard.ahu.edu.cn/payment/?name=result" - val userAgent = "h5" - val synAccessSource = "h5" - - addParams( - mapOf( - "paytypeid" to payTypeId, - "paytype" to payType, +package com.ahu.ahutong.data.crawler.model.ycard + +import com.ahu.ahutong.data.crawler.utils.generateNonce +import com.ahu.ahutong.data.crawler.utils.getTimestamp +import com.ahu.ahutong.data.crawler.utils.sha256 +import com.ahu.ahutong.data.model.CardRechargeBank + +class CardPayRequest(orderId: String, bank: CardRechargeBank) : RequestBody() { + + init { + val time = getTimestamp() + val nonce = generateNonce() + val appId = "56321" + val payStep = "2" + val (payType, payTypeId) = when (bank) { + CardRechargeBank.AGRICULTURAL_BANK -> "BANKCARD" to "63" + CardRechargeBank.CHINA_MERCHANTS_BANK -> "PAYMENTCASHIER" to "81" + CardRechargeBank.ALIPAY -> error("Alipay recharge is handled outside the campus-card API") + } + val redirectUrl = "https://ycard.ahu.edu.cn/payment/?name=result" + val userAgent = "h5" + val synAccessSource = "h5" + + addParams( + mapOf( + "opAppId" to "", + "paytypeid" to payTypeId, + "paytype" to payType, "paystep" to payStep, "orderid" to orderId, "redirect_url" to redirectUrl, "userAgent" to userAgent, "APP_ID" to appId, - "TIMESTAMP" to time, - "SIGN_TYPE" to "SHA256", - "NONCE" to nonce, - "SIGN" to sha256("APP_ID=56321&NONCE=$nonce&SIGN_TYPE=SHA256&TIMESTAMP=$time&orderid=$orderId&paystep=2&paytype=BANKCARD&paytypeid=63&redirect_url=https://ycard.ahu.edu.cn/payment/?name=result&userAgent=h5&SECRET_KEY=0osTIhce7uPvDKHz6aa67bhCukaKoYl4").uppercase(), - "synAccessSource" to synAccessSource - ) + "TIMESTAMP" to time, + "SIGN_TYPE" to "SHA256", + "NONCE" to nonce, + "SIGN" to sha256("APP_ID=$appId&NONCE=$nonce&SIGN_TYPE=SHA256&TIMESTAMP=$time&orderid=$orderId&paystep=$payStep&paytype=$payType&paytypeid=$payTypeId&redirect_url=$redirectUrl&userAgent=$userAgent&SECRET_KEY=0osTIhce7uPvDKHz6aa67bhCukaKoYl4").uppercase(), + "synAccessSource" to synAccessSource + ) ) } diff --git a/app/src/main/java/com/ahu/ahutong/data/crawler/net/AutoLoginInterceptor.kt b/app/src/main/java/com/ahu/ahutong/data/crawler/net/AutoLoginInterceptor.kt index 5db57a44..0f16722d 100644 --- a/app/src/main/java/com/ahu/ahutong/data/crawler/net/AutoLoginInterceptor.kt +++ b/app/src/main/java/com/ahu/ahutong/data/crawler/net/AutoLoginInterceptor.kt @@ -1,9 +1,8 @@ package com.ahu.ahutong.data.crawler.net import android.util.Log -import com.ahu.ahutong.AHUApplication -import okhttp3.Interceptor -import okhttp3.Response +import okhttp3.Interceptor +import okhttp3.Response class AutoLoginInterceptor : Interceptor { @@ -11,18 +10,22 @@ class AutoLoginInterceptor : Interceptor { val TAG = "AutoLoginInterceptor" override fun intercept(chain: Interceptor.Chain): Response { - val originalRequest = chain.request() - val response = chain.proceed(originalRequest) + val originalRequest = SessionRefreshCoordinator.tagRequest(chain.request()) + val response = chain.proceed(originalRequest) Log.d(TAG, "first-party request completed with status=${response.code}") - val location = response.header("Location") - if (response.code == 302 && location != null && (location.contains("tologin") || location.contains("refer"))) { - Log.e(TAG, "intercept: token expired!", ) - AHUApplication.sessionExpired = true - return response.newBuilder() - .code(401) - .build() - } + val location = response.header("Location") + if ( + response.code in 300..399 && + SessionRefreshPolicy.isFirstPartyLoginRedirect(originalRequest.url, location) + ) { + Log.i(TAG, "First-party session redirect detected") + SessionRefreshCoordinator.markExpired() + return response.newBuilder() + .code(401) + .header(SessionRefreshPolicy.EXPIRED_RESPONSE_HEADER, "1") + .build() + } return response } diff --git a/app/src/main/java/com/ahu/ahutong/data/crawler/net/SessionRefreshCoordinator.kt b/app/src/main/java/com/ahu/ahutong/data/crawler/net/SessionRefreshCoordinator.kt new file mode 100644 index 00000000..922464fd --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/data/crawler/net/SessionRefreshCoordinator.kt @@ -0,0 +1,68 @@ +package com.ahu.ahutong.data.crawler.net + +import com.ahu.ahutong.AHUApplication +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.HttpUrl +import okhttp3.Request + +/** Coordinates one first-party re-login for a burst of expired requests. */ +object SessionRefreshCoordinator { + private val refreshMutex = Mutex() + + @Volatile + private var generation = 0L + + fun currentGeneration(): Long = generation + + /** + * Pins the session generation that was current when a request actually left the client. + * A slow response from the old session can otherwise arrive just after a successful refresh + * and incorrectly start another full login. + */ + fun tagRequest(request: Request): Request { + if (request.tag(SessionRequestGeneration::class.java) != null) return request + return request.newBuilder() + .tag(SessionRequestGeneration::class.java, SessionRequestGeneration(generation)) + .build() + } + + fun observedGeneration(request: Request): Long = + request.tag(SessionRequestGeneration::class.java)?.value ?: generation + + fun markExpired() { + AHUApplication.sessionExpired = true + } + + suspend fun refreshIfNeeded( + observedGeneration: Long, + refresh: suspend () -> Boolean + ): Boolean = refreshMutex.withLock { + if (generation != observedGeneration) return@withLock true + if (!refresh()) return@withLock false + + generation += 1 + AHUApplication.sessionExpired = false + true + } +} + +internal data class SessionRequestGeneration(val value: Long) + +internal object SessionRefreshPolicy { + const val EXPIRED_RESPONSE_HEADER = "X-AHUTong-Session-Expired" + + fun isMarkedExpired(responseHeader: String?): Boolean = responseHeader == "1" + + fun isFirstPartyLoginRedirect(requestUrl: HttpUrl, location: String?): Boolean { + val target = location?.let(requestUrl::resolve) ?: return false + val host = target.host.lowercase() + if (host != "ahu.edu.cn" && !host.endsWith(".ahu.edu.cn")) return false + + val path = target.encodedPath.lowercase() + val hasLoginPath = path.contains("tologin") || + path.contains("/login") || + path.contains("/cas/") + return hasLoginPath + } +} diff --git a/app/src/main/java/com/ahu/ahutong/data/crawler/net/TokenAuthenticator.kt b/app/src/main/java/com/ahu/ahutong/data/crawler/net/TokenAuthenticator.kt index b3a60451..97dc2286 100644 --- a/app/src/main/java/com/ahu/ahutong/data/crawler/net/TokenAuthenticator.kt +++ b/app/src/main/java/com/ahu/ahutong/data/crawler/net/TokenAuthenticator.kt @@ -1,71 +1,66 @@ -package com.ahu.ahutong.data.crawler.net - -import android.util.Log -import com.ahu.ahutong.AHUApplication -import com.ahu.ahutong.data.AHURepository -import com.ahu.ahutong.data.crawler.manager.CookieManager -import com.ahu.ahutong.data.crawler.manager.TokenManager -import com.ahu.ahutong.data.dao.AHUCache -import kotlinx.coroutines.runBlocking -import okhttp3.Authenticator -import okhttp3.Request -import okhttp3.Response -import okhttp3.Route - -class TokenAuthenticator : Authenticator { - - val TAG = "TokenAuthenticator" - - override fun authenticate(route: Route?, response: Response): Request? { - - if (response.request.header("Authorization") != null && response.code == 302) { - Log.e(TAG, "authenticate: 这是什么情况?", ) - return null - } - - - // 每个接口发现重定向都可能会进入触发重新登录,这里要保证只有一个请求在重新登录 - synchronized(AHUApplication.reLoginMutex) { - - - - if (!AHUApplication.sessionExpired) { // 新请求如果发现之前有重新登录成功了,那就直接重新构造请求 - Log.e(TAG, "authenticate: 成功登录了", ) - return response.request.newBuilder() - .build() - } - - - // - Log.e(TAG, "authenticate: 第一方会话过期,尝试重新登录", ) - return runBlocking { - CookieManager.cookieJar.clear() - TokenManager.clear() - - - AHUCache.getCurrentUser()?.let{ - val loginResponse = AHURepository.loginWithCrawler( - it.xh.toString(), - AHUCache.getWisdomPassword().toString() - ) - - if (loginResponse.isSuccessful) { - AHUApplication.sessionExpired = false - Log.e(TAG, "authenticate: 登录成功", ) - return@runBlocking response.request.newBuilder() - .build() - } else { - AHUApplication.sessionExpired = true - Log.e(TAG, "authenticate: 登录失败了", ) - return@runBlocking null - } - } - - Log.e(TAG, "authenticate: 未找到用户信息", ) - AHUApplication.sessionExpired = true - return@runBlocking null - } - } - - } +package com.ahu.ahutong.data.crawler.net + +import android.util.Log +import com.ahu.ahutong.AHUApplication +import com.ahu.ahutong.data.AHURepository +import com.ahu.ahutong.data.crawler.manager.TokenManager +import com.ahu.ahutong.data.dao.AHUCache +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route + +class TokenAuthenticator : Authenticator { + override fun authenticate(route: Route?, response: Response): Request? { + if (responseCount(response) >= MAX_ATTEMPTS) return null + if (!SessionRefreshPolicy.isMarkedExpired( + response.header(SessionRefreshPolicy.EXPIRED_RESPONSE_HEADER) + ) + ) return null + + val observedGeneration = SessionRefreshCoordinator.observedGeneration(response.request) + return runBlocking { + val refreshed = SessionRefreshCoordinator.refreshIfNeeded(observedGeneration) { + val user = AHUCache.getCurrentUser() ?: return@refreshIfNeeded false + val password = AHUCache.getWisdomPassword()?.takeIf { it.isNotBlank() } + ?: return@refreshIfNeeded false + + Log.i(TAG, "Refreshing expired first-party session") + val loginResponse = AHURepository.loginWithCrawler( + username = user.xh.toString(), + password = password, + preferNative = false + ) + if (!loginResponse.isSuccessful) { + AHUApplication.sessionExpired = true + Log.w(TAG, "Session refresh failed") + return@refreshIfNeeded false + } + + TokenManager.clear() + true + } + if (!refreshed) return@runBlocking null + + response.request.newBuilder() + .removeHeader("Cookie") + .build() + } + } + + private fun responseCount(response: Response): Int { + var count = 1 + var prior = response.priorResponse + while (prior != null) { + count++ + prior = prior.priorResponse + } + return count + } + + private companion object { + const val TAG = "TokenAuthenticator" + const val MAX_ATTEMPTS = 2 + } } diff --git a/app/src/main/java/com/ahu/ahutong/data/dao/AHUCache.kt b/app/src/main/java/com/ahu/ahutong/data/dao/AHUCache.kt index 9f96deff..9fc76775 100644 --- a/app/src/main/java/com/ahu/ahutong/data/dao/AHUCache.kt +++ b/app/src/main/java/com/ahu/ahutong/data/dao/AHUCache.kt @@ -1,12 +1,15 @@ package com.ahu.ahutong.data.dao import com.ahu.ahutong.AHUApplication +import com.ahu.ahutong.BuildConfig import com.ahu.ahutong.data.crawler.model.adwnh.CampusItem import com.ahu.ahutong.data.crawler.model.adwnh.LostFoundItem import com.ahu.ahutong.data.crawler.model.adwnh.LostFoundTypeItem import com.ahu.ahutong.data.model.Course import com.ahu.ahutong.data.model.ElectricityChargeInfo +import com.ahu.ahutong.data.model.ElectricityController import com.ahu.ahutong.data.model.ElectricityDepositHistoryItem +import com.ahu.ahutong.data.model.CardRechargeBank import com.ahu.ahutong.data.model.EvalPreset import com.ahu.ahutong.data.model.Exam import com.ahu.ahutong.data.model.GpaRankInfo @@ -14,6 +17,7 @@ import com.ahu.ahutong.data.model.Grade import com.ahu.ahutong.data.model.GradeStudentProfile import com.ahu.ahutong.data.model.RoomSelectionInfo import com.ahu.ahutong.data.model.User +import com.ahu.ahutong.data.security.SecureStorage import com.ahu.ahutong.ext.fromJson import com.ahu.ahutong.sdk.RustSDK import com.google.gson.Gson @@ -32,6 +36,20 @@ object AHUCache { } private val kv_init: MMKV = MMKV.mmkvWithID("ahu") + + private val currentUserCacheLock = Any() + @Volatile + private var currentUserCacheInitialized = false + @Volatile + private var currentUserCache: User? = null + + @Volatile + private var mockDataCache: Boolean? = null + @Volatile + private var mockCurrentTimeCacheInitialized = false + @Volatile + private var mockCurrentTimeCache: Long? = null + private val kv: MMKV get() { val user = getCurrentUser() @@ -48,47 +66,68 @@ object AHUCache { return value.replace(Regex("[^A-Za-z0-9_.-]"), "_") } - private fun userBoxName(): String { - val userId = getCurrentUser()?.xh?.takeIf { it.isNotEmpty() } ?: "guest" - return "user_${sanitizeBoxPart(userId)}" + private fun userBoxName(userId: String? = getCurrentUser()?.xh): String { + val stableUserId = userId?.takeIf { it.isNotEmpty() } ?: "guest" + return "user_${sanitizeBoxPart(stableUserId)}" } private fun initPutString(key: String, value: String) { - RustSDK.kvPutStringSafe(INIT_BOX, key, value) + SecureStorage.putString("$INIT_BOX.$key", value) + RustSDK.kvRemoveSafe(INIT_BOX, key) + kv_init.removeValueForKey(key) } private fun initGetString(key: String): String? { - return RustSDK.kvGetStringSafe(INIT_BOX, key) + SecureStorage.getString("$INIT_BOX.$key")?.let { return it } + return RustSDK.kvGetStringSafe(INIT_BOX, key)?.also { value -> + SecureStorage.putString("$INIT_BOX.$key", value) + RustSDK.kvRemoveSafe(INIT_BOX, key) + } } private fun initGetStringOrMigrate(key: String, fallback: () -> String?): String? { initGetString(key)?.let { return it } - return fallback()?.also { - if (it.isNotEmpty()) initPutString(key, it) + return fallback()?.also { value -> + if (value.isNotEmpty()) initPutString(key, value) + kv_init.removeValueForKey(key) } } private fun initRemove(key: String) { + SecureStorage.remove("$INIT_BOX.$key") RustSDK.kvRemoveSafe(INIT_BOX, key) + kv_init.removeValueForKey(key) } private fun userPutString(key: String, value: String) { - RustSDK.kvPutStringSafe(userBoxName(), key, value) + val boxName = userBoxName() + SecureStorage.putString("$boxName.$key", value) + RustSDK.kvRemoveSafe(boxName, key) + kv.removeValueForKey(key) } private fun userGetString(key: String): String? { - return RustSDK.kvGetStringSafe(userBoxName(), key) + val boxName = userBoxName() + SecureStorage.getString("$boxName.$key")?.let { return it } + return RustSDK.kvGetStringSafe(boxName, key)?.also { value -> + SecureStorage.putString("$boxName.$key", value) + RustSDK.kvRemoveSafe(boxName, key) + } } private fun userGetStringOrMigrate(key: String, fallback: () -> String?): String? { userGetString(key)?.let { return it } - return fallback()?.also { - if (it.isNotEmpty()) userPutString(key, it) + return fallback()?.also { value -> + if (value.isNotEmpty()) userPutString(key, value) + kv.removeValueForKey(key) } } private fun userRemove(key: String) { - RustSDK.kvRemoveSafe(userBoxName(), key) + val boxName = userBoxName() + SecureStorage.remove("$boxName.$key") + RustSDK.kvRemoveSafe(boxName, key) + kv.removeValueForKey(key) } /** @@ -97,12 +136,23 @@ object AHUCache { fun clearAll() { val boxName = userBoxName() val currentKv = kv + SecureStorage.clearPrefix("$INIT_BOX.") + SecureStorage.clearPrefix("$boxName.") + SecureStorage.clearPrefix("user_guest.") RustSDK.kvClearBoxSafe(INIT_BOX) RustSDK.kvClearBoxSafe(boxName) RustSDK.kvClearBoxSafe("user_guest") kv_init.clearAll() currentKv.clearAll() MMKV.mmkvWithID("ahu_guest").clearAll() + synchronized(currentUserCacheLock) { + currentUserCache = null + currentUserCacheInitialized = true + } + mockDataCache = null + mockCurrentTimeCache = null + mockCurrentTimeCacheInitialized = false + homeWidgetSlotsCache = null } /** @@ -112,15 +162,23 @@ object AHUCache { fun saveCurrentUser(user: User) { val data = Gson().toJson(user) initPutString("current_user", data) - kv_init.encode("current_user", data) + synchronized(currentUserCacheLock) { + currentUserCache = user + currentUserCacheInitialized = true + } + homeWidgetSlotsCache = null } /** * 清除本地登陆状态 */ fun clearCurrentUser() { - initPutString("current_user", "") - kv_init.encode("current_user", "") + initRemove("current_user") + synchronized(currentUserCacheLock) { + currentUserCache = null + currentUserCacheInitialized = true + } + homeWidgetSlotsCache = null } /** @@ -128,8 +186,20 @@ object AHUCache { * @return User? */ fun getCurrentUser(): User? { - val data = initGetStringOrMigrate("current_user") { kv_init.decodeString("current_user") } ?: "" - return data.fromJson(User::class.java) + if (currentUserCacheInitialized) return currentUserCache + return synchronized(currentUserCacheLock) { + if (currentUserCacheInitialized) { + currentUserCache + } else { + val data = initGetStringOrMigrate("current_user") { + kv_init.decodeString("current_user") + }.orEmpty() + data.fromJson(User::class.java).also { user -> + currentUserCache = user + currentUserCacheInitialized = true + } + } + } } /** @@ -145,8 +215,8 @@ object AHUCache { * @param password String */ fun saveWisdomPassword(password: String) { - initPutString("password_wisdom", password) - kv_init.encode("password_wisdom", password) + if (password.isEmpty()) initRemove("password_wisdom") + else initPutString("password_wisdom", password) } /** @@ -158,8 +228,8 @@ object AHUCache { } fun saveEvalToken(token: String) { - userPutString("eval_token", token) - kv.encode("eval_token", token) + if (token.isEmpty()) userRemove("eval_token") + else userPutString("eval_token", token) } fun getEvalToken(): String? { @@ -169,7 +239,6 @@ object AHUCache { fun saveEvalPreset(preset: EvalPreset) { val data = Gson().toJson(preset) userPutString("eval_preset", data) - kv.encode("eval_preset", data) } fun getEvalPreset(): EvalPreset { @@ -187,13 +256,11 @@ object AHUCache { fun saveSchedule(schoolYear: String, schoolTerm: String, schedule: List) { val data = Gson().toJson(schedule) userPutString("$schoolYear-$schoolTerm.schedule", data) - kv.putString("$schoolYear-$schoolTerm.schedule", data) } fun saveSchedule(schoolTerm: String,schedule: List) { val data = Gson().toJson(schedule) userPutString("$schoolTerm.schedule", data) - kv.putString("$schoolTerm.schedule", data) // 2025-2026-1 } /** @@ -216,11 +283,13 @@ object AHUCache { fun saveNextSchedule(schedule: List) { val data = Gson().toJson(schedule) - kv.putString("next.schedule", data) + userPutString("next.schedule", data) } fun getNextSchedule(): List? { - val data = kv.getString("next.schedule", "") ?: "" + val data = userGetStringOrMigrate("next.schedule") { + kv.getString("next.schedule", "") + } ?: "" return data.fromJson(object : TypeToken>() {}.type) } @@ -231,7 +300,6 @@ object AHUCache { fun saveGrade(grade: Grade) { val data = Gson().toJson(grade) userPutString("grade", data) - kv.encode("grade", data) } /** @@ -250,7 +318,7 @@ object AHUCache { fun saveExamInfo(exams: List) { val data = Gson().toJson(exams) userPutString("exams", data) - kv.encode("exams", data) + userPutString("exams_updated_at", System.currentTimeMillis().toString()) } /** @@ -262,6 +330,10 @@ object AHUCache { return data.fromJson(object : TypeToken>() {}.type) } + fun getExamInfoUpdatedAt(): Long { + return userGetString("exams_updated_at")?.toLongOrNull() ?: 0L + } + /** * 获取开学时间 * @param schoolYear String yyyy-yyyy @@ -281,7 +353,6 @@ object AHUCache { */ fun saveSchoolTermStartTime(schoolYear: String, schoolTerm: String, startTime: String) { userPutString("startTime-$schoolYear-$schoolTerm", startTime) - kv.encode("startTime-$schoolYear-$schoolTerm", startTime) } fun getSchoolTermInSemester(schoolYear: String, schoolTerm: String): Boolean? { @@ -304,11 +375,9 @@ object AHUCache { val key = "inSemester-$schoolYear-$schoolTerm" val value = isInSemester.toString() userPutString(key, value) - kv.encode(key, value) val observedOnKey = "inSemesterObservedOn-$schoolYear-$schoolTerm" userPutString(observedOnKey, observedOn) - kv.encode(observedOnKey, observedOn) } /** @@ -328,7 +397,6 @@ object AHUCache { */ fun saveSchoolYear(schoolYear: String) { userPutString("defaultSchoolYear", schoolYear) - kv.encode("defaultSchoolYear", schoolYear) } /** @@ -350,7 +418,6 @@ object AHUCache { */ fun saveSchoolTerm(schoolTerm: String) { userPutString("defaultSchoolTerm", schoolTerm) - kv.putString("defaultSchoolTerm", schoolTerm) } /** @@ -370,7 +437,6 @@ object AHUCache { */ fun saveIsShowAllCourse(isCourse: Boolean) { userPutString("isShowAllCourse", isCourse.toString()) - kv.putBoolean("isShowAllCourse", isCourse) } fun isShowWidgetTip(): Boolean { @@ -382,12 +448,19 @@ object AHUCache { fun ignoreWidgetTip() { userPutString("is_show_widget_dialog", false.toString()) - kv.putBoolean("is_show_widget_dialog", false) } private const val HOME_WIDGET_SLOTS_KEY = "home_widget_slots" private const val HOME_WIDGET_SLOT_COUNT = 8 + private data class HomeWidgetSlotsCache( + val userId: String?, + val slots: List + ) + + @Volatile + private var homeWidgetSlotsCache: HomeWidgetSlotsCache? = null + private fun defaultHomeWidgetSlots(): List { return listOf("bathroom", "electricity") + List(HOME_WIDGET_SLOT_COUNT - 2) { null } } @@ -401,39 +474,55 @@ object AHUCache { } fun getHomeWidgetSlots(): List { + val userId = getCurrentUser()?.xh + homeWidgetSlotsCache + ?.takeIf { it.userId == userId } + ?.let { return it.slots } val data = userGetStringOrMigrate(HOME_WIDGET_SLOTS_KEY) { kv.decodeString(HOME_WIDGET_SLOTS_KEY) } ?: "" - if (data.isBlank()) return defaultHomeWidgetSlots() - - return runCatching { - Gson().fromJson>( - data, - object : TypeToken>() {}.type - ) - }.getOrNull() - ?.let(::normalizeHomeWidgetSlots) - ?: defaultHomeWidgetSlots() + val slots = if (data.isBlank()) { + defaultHomeWidgetSlots() + } else { + runCatching { + Gson().fromJson>( + data, + object : TypeToken>() {}.type + ) + }.getOrNull() + ?.let(::normalizeHomeWidgetSlots) + ?: defaultHomeWidgetSlots() + } + homeWidgetSlotsCache = HomeWidgetSlotsCache(userId, slots) + return slots } fun saveHomeWidgetSlots(slots: List) { val normalizedSlots = normalizeHomeWidgetSlots(slots) val data = Gson().toJson(normalizedSlots) userPutString(HOME_WIDGET_SLOTS_KEY, data) - kv.encode(HOME_WIDGET_SLOTS_KEY, data) + homeWidgetSlotsCache = HomeWidgetSlotsCache(getCurrentUser()?.xh, normalizedSlots) } fun logout() { - clearCurrentUser() + val userId = getCurrentUser()?.xh + val boxName = userBoxName(userId) + val currentUserKv = if (userId.isNullOrEmpty()) { + MMKV.mmkvWithID("ahu_guest") + } else { + MMKV.mmkvWithID("ahu_$userId") + } + SecureStorage.clearPrefix("$boxName.") + RustSDK.kvClearBoxSafe(boxName) + currentUserKv.clearAll() saveWisdomPassword("") - saveEvalToken("") saveRustCookies("") + clearCurrentUser() } fun savePhone(phone:String){ userPutString("phone", phone) - kv.putString("phone",phone) } fun getPhone() : String?{ @@ -443,7 +532,6 @@ object AHUCache { fun setJwxtStudentId(id: String){ userPutString("jwxt_stu_id", id) - kv.putString("jwxt_stu_id",id) } fun getJwxtStudentId() : String?{ @@ -458,7 +546,6 @@ object AHUCache { fun setGradeStudentProfiles(profiles: List) { val data = Gson().toJson(profiles) userPutString("jwxt_student_profiles", data) - kv.encode("jwxt_student_profiles", data) } fun getGradeStudentProfiles(): List { @@ -474,7 +561,6 @@ object AHUCache { val idMap = map.mapKeys { it.key.id } val data = Gson().toJson(idMap) userPutString("per_profile_grades", data) - kv.encode("per_profile_grades", data) } fun getPerProfileGrades(): Map { @@ -488,7 +574,6 @@ object AHUCache { fun saveString(key: String ,value : String){ userPutString(key, value) - kv.putString(key,value) } fun saveRustCookies(cookiesJson: String) { @@ -497,7 +582,6 @@ object AHUCache { } else { initPutString("rust_cookies_json", cookiesJson) } - kv_init.putString("rust_cookies_json", cookiesJson) } fun getRustCookies(): String { @@ -516,7 +600,6 @@ object AHUCache { fun setAgreementAccepted(){ userPutString("agreementAccepted", true.toString()) - kv.putBoolean("agreementAccepted",true) } fun isPrivacyAccepted(): Boolean{ @@ -528,7 +611,6 @@ object AHUCache { fun setPrivacyAccepted(){ userPutString("privacyAccepted", true.toString()) - kv.putBoolean("privacyAccepted",true) } fun isBusinessAccepted(): Boolean{ @@ -540,21 +622,62 @@ object AHUCache { fun setBusinessAccepted(){ userPutString("businessAccepted", true.toString()) - kv.putBoolean("businessAccepted",true) } - fun isCmbCardRechargePreferred(): Boolean { - userGetString("cmb_card_recharge_preferred")?.toBooleanStrictOrNull()?.let { return it } - val value = kv.getBoolean("cmb_card_recharge_preferred", false) - if (kv.containsKey("cmb_card_recharge_preferred")) { - userPutString("cmb_card_recharge_preferred", value.toString()) + fun getCardRechargeBank(): CardRechargeBank? { + CardRechargeBank.fromStorage(userGetString("card_recharge_bank"))?.let { return it } + CardRechargeBank.fromStorage(kv.decodeString("card_recharge_bank"))?.let { bank -> + userPutString("card_recharge_bank", bank.storageValue) + return bank } - return value + + val legacyValue = userGetString("cmb_card_recharge_preferred") + ?.toBooleanStrictOrNull() + ?: if (kv.containsKey("cmb_card_recharge_preferred")) { + kv.getBoolean("cmb_card_recharge_preferred", false) + } else { + null + } + return legacyValue?.let { preferred -> + val bank = if (preferred) { + CardRechargeBank.CHINA_MERCHANTS_BANK + } else { + CardRechargeBank.AGRICULTURAL_BANK + } + setCardRechargeBank(bank) + bank + } + } + + fun setCardRechargeBank(bank: CardRechargeBank) { + userPutString("card_recharge_bank", bank.storageValue) + kv.putString("card_recharge_bank", bank.storageValue) } + fun isCmbCardRechargePreferred(): Boolean = + getCardRechargeBank() == CardRechargeBank.CHINA_MERCHANTS_BANK + fun setCmbCardRechargePreferred(preferred: Boolean) { - userPutString("cmb_card_recharge_preferred", preferred.toString()) - kv.putBoolean("cmb_card_recharge_preferred", preferred) + setCardRechargeBank( + if (preferred) { + CardRechargeBank.CHINA_MERCHANTS_BANK + } else { + CardRechargeBank.AGRICULTURAL_BANK + } + ) + } + + fun getElectricityController(): ElectricityController { + val value = userGetStringOrMigrate("electricity_controller") { + kv.decodeString("electricity_controller") + } + return ElectricityController.entries.firstOrNull { it.name == value } + ?: ElectricityController.C + } + + fun setElectricityController(controller: ElectricityController) { + userPutString("electricity_controller", controller.name) + kv.putString("electricity_controller", controller.name) } /** @@ -571,7 +694,6 @@ object AHUCache { fun saveElectricityDepositHistory(history: List) { val data = Gson().toJson(history) userPutString("electricity_room_history", data) - kv.encode("electricity_room_history", data) } fun getElectricityDepositHistory(): List { @@ -592,7 +714,6 @@ object AHUCache { fun saveElectricityChargeInfo(info: ElectricityChargeInfo) { val data = Gson().toJson(info) userPutString("electricity_charge_acl", data) - kv.encode("electricity_charge_acl", data) } /** @@ -627,7 +748,6 @@ object AHUCache { fun saveRoomSelection(info: RoomSelectionInfo) { val data = Gson().toJson(info) userPutString("room_selection_info", data) - kv.encode("room_selection_info", data) } /** @@ -636,7 +756,6 @@ object AHUCache { */ fun saveCardBalance(balance: Double) { userPutString("card_balance", balance.toString()) - kv.encode("card_balance", balance) } /** @@ -652,23 +771,38 @@ object AHUCache { } fun getMockData(): Boolean { - initGetString("mock_data")?.toBooleanStrictOrNull()?.let { return it } - if (!kv.containsKey("mock_data")) return false - return kv.decodeBool("mock_data").also { - initPutString("mock_data", it.toString()) + if (!BuildConfig.DEBUG) { + mockDataCache = false + return false } + mockDataCache?.let { return it } + val value = initGetString("mock_data")?.toBooleanStrictOrNull() + ?: if (!kv.containsKey("mock_data")) { + false + } else { + kv.decodeBool("mock_data").also { + initPutString("mock_data", it.toString()) + } + } + mockDataCache = value + return value } fun setMockData(enable: Boolean) { + if (!BuildConfig.DEBUG) { + initRemove("mock_data") + kv.removeValueForKey("mock_data") + mockDataCache = false + return + } initPutString("mock_data", enable.toString()) - kv.encode("mock_data", enable) + mockDataCache = enable } // === 天气 adcode 缓存(用于精准到区级) === fun saveWeatherAdcode(adcode: String) { initPutString("weather_adcode", adcode) - kv_init.encode("weather_adcode", adcode) } fun getWeatherAdcode(): String? { @@ -678,21 +812,33 @@ object AHUCache { } fun saveMockCurrentTimeMillis(value: Long) { + if (!BuildConfig.DEBUG) return initPutString("mock_current_time_millis", value.toString()) - kv.encode("mock_current_time_millis", value) + mockCurrentTimeCache = value + mockCurrentTimeCacheInitialized = true } fun getMockCurrentTimeMillis(): Long? { - initGetString("mock_current_time_millis")?.toLongOrNull()?.let { return it } - if (!kv.containsKey("mock_current_time_millis")) return null - return kv.decodeLong("mock_current_time_millis").also { - initPutString("mock_current_time_millis", it.toString()) - } + if (!BuildConfig.DEBUG) return null + if (mockCurrentTimeCacheInitialized) return mockCurrentTimeCache + val value = initGetString("mock_current_time_millis")?.toLongOrNull() + ?: if (!kv.containsKey("mock_current_time_millis")) { + null + } else { + kv.decodeLong("mock_current_time_millis").also { + initPutString("mock_current_time_millis", it.toString()) + } + } + mockCurrentTimeCache = value + mockCurrentTimeCacheInitialized = true + return value } fun clearMockCurrentTimeMillis() { initRemove("mock_current_time_millis") kv.removeValueForKey("mock_current_time_millis") + mockCurrentTimeCache = null + mockCurrentTimeCacheInitialized = true } fun getGrayOverride(key: String): String? { @@ -715,7 +861,6 @@ object AHUCache { map[studentId] = gpaRankInfo val data = Gson().toJson(map) userPutString("gpa_rank_info_map", data) - kv.encode("gpa_rank_info_map", data) } /** * 获取指定 studentId 的缓存 GPA 排名信息 @@ -744,18 +889,16 @@ object AHUCache { * 保存失物招领校区缓存 */ fun saveLostFoundCampus(campus: List) { - kv.encode( - "lost_found_campus", - Gson().toJson(campus) - ) + userPutString("lost_found_campus", Gson().toJson(campus)) } /** * 获取失物招领校区缓存 */ fun getLostFoundCampus(): List { - val data = - kv.decodeString("lost_found_campus") ?: "" + val data = userGetStringOrMigrate("lost_found_campus") { + kv.decodeString("lost_found_campus") + } ?: "" if (data.isEmpty()) return emptyList() @@ -768,18 +911,16 @@ object AHUCache { * 保存失物招领类型缓存 */ fun saveLostFoundType(types: List) { - kv.encode( - "lost_found_type", - Gson().toJson(types) - ) + userPutString("lost_found_type", Gson().toJson(types)) } /** * 获取失物招领类型缓存 */ fun getLostFoundType(): List { - val data = - kv.decodeString("lost_found_type") ?: "" + val data = userGetStringOrMigrate("lost_found_type") { + kv.decodeString("lost_found_type") + } ?: "" if (data.isEmpty()) return emptyList() @@ -795,10 +936,7 @@ object AHUCache { state: Int, items: List ) { - kv.encode( - "lost_found_list_$state", - Gson().toJson(items) - ) + userPutString("lost_found_list_$state", Gson().toJson(items)) } /** @@ -807,10 +945,8 @@ object AHUCache { fun getLostFoundList( state: Int ): List { - val data = - kv.decodeString( - "lost_found_list_$state" - ) ?: "" + val key = "lost_found_list_$state" + val data = userGetStringOrMigrate(key) { kv.decodeString(key) } ?: "" if (data.isEmpty()) return emptyList() @@ -844,19 +980,17 @@ object AHUCache { fun clearLostFoundList( state: Int ) { - kv.removeValueForKey( - "lost_found_list_$state" - ) + userRemove("lost_found_list_$state") } /** * 清除全部失物招领缓存 */ fun clearLostFoundCache() { - kv.removeValueForKey("lost_found_campus") - kv.removeValueForKey("lost_found_type") - kv.removeValueForKey("lost_found_list_1") - kv.removeValueForKey("lost_found_list_2") + userRemove("lost_found_campus") + userRemove("lost_found_type") + userRemove("lost_found_list_1") + userRemove("lost_found_list_2") } /** @@ -870,50 +1004,62 @@ object AHUCache { private const val WEATHER_HOME_SHOW_LOCATION_KEY = "weather_home_show_location" fun saveWeatherShowOnHome(enabled: Boolean) { - kv.encode(WEATHER_SHOW_ON_HOME_KEY, enabled) + userPutString(WEATHER_SHOW_ON_HOME_KEY, enabled.toString()) } fun getWeatherShowOnHome(): Boolean { - return kv.decodeBool(WEATHER_SHOW_ON_HOME_KEY, false) + userGetString(WEATHER_SHOW_ON_HOME_KEY)?.toBooleanStrictOrNull()?.let { return it } + val value = kv.decodeBool(WEATHER_SHOW_ON_HOME_KEY, false) + if (kv.containsKey(WEATHER_SHOW_ON_HOME_KEY)) userPutString(WEATHER_SHOW_ON_HOME_KEY, value.toString()) + return value } fun saveWeatherHomeMode(mode: String) { - kv.encode(WEATHER_HOME_MODE_KEY, mode) + userPutString(WEATHER_HOME_MODE_KEY, mode) } fun getWeatherHomeMode(): String { - return kv.decodeString(WEATHER_HOME_MODE_KEY) ?: "detailed" + return userGetStringOrMigrate(WEATHER_HOME_MODE_KEY) { + kv.decodeString(WEATHER_HOME_MODE_KEY) + } ?: "detailed" } fun saveWeatherHomeShowTemp(enabled: Boolean) { - kv.encode(WEATHER_HOME_SHOW_TEMP_KEY, enabled) + userPutString(WEATHER_HOME_SHOW_TEMP_KEY, enabled.toString()) } fun getWeatherHomeShowTemp(): Boolean { - return kv.decodeBool(WEATHER_HOME_SHOW_TEMP_KEY, true) + return getUserBooleanOrMigrate(WEATHER_HOME_SHOW_TEMP_KEY, true) } fun saveWeatherHomeShowWeather(enabled: Boolean) { - kv.encode(WEATHER_HOME_SHOW_WEATHER_KEY, enabled) + userPutString(WEATHER_HOME_SHOW_WEATHER_KEY, enabled.toString()) } fun getWeatherHomeShowWeather(): Boolean { - return kv.decodeBool(WEATHER_HOME_SHOW_WEATHER_KEY, true) + return getUserBooleanOrMigrate(WEATHER_HOME_SHOW_WEATHER_KEY, true) } fun saveWeatherHomeShowAqi(enabled: Boolean) { - kv.encode(WEATHER_HOME_SHOW_AQI_KEY, enabled) + userPutString(WEATHER_HOME_SHOW_AQI_KEY, enabled.toString()) } fun getWeatherHomeShowAqi(): Boolean { - return kv.decodeBool(WEATHER_HOME_SHOW_AQI_KEY, true) + return getUserBooleanOrMigrate(WEATHER_HOME_SHOW_AQI_KEY, true) } fun saveWeatherHomeShowLocation(enabled: Boolean) { - kv.encode(WEATHER_HOME_SHOW_LOCATION_KEY, enabled) + userPutString(WEATHER_HOME_SHOW_LOCATION_KEY, enabled.toString()) } fun getWeatherHomeShowLocation(): Boolean { - return kv.decodeBool(WEATHER_HOME_SHOW_LOCATION_KEY, true) + return getUserBooleanOrMigrate(WEATHER_HOME_SHOW_LOCATION_KEY, true) + } + + private fun getUserBooleanOrMigrate(key: String, defaultValue: Boolean): Boolean { + userGetString(key)?.toBooleanStrictOrNull()?.let { return it } + val value = kv.decodeBool(key, defaultValue) + if (kv.containsKey(key)) userPutString(key, value.toString()) + return value } } 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 1ccfc44b..d181a185 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 @@ -8,6 +8,7 @@ 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 com.ahu.ahutong.data.model.AppUiTheme import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -17,6 +18,9 @@ object PreferencesKeys { val SHOW_QR_CODE = booleanPreferencesKey("show_qr_code") val IS_SHOW_ALL_COURSE = booleanPreferencesKey("is_show_all_course") val USE_LIQUID_GLASS = booleanPreferencesKey("use_liquid_glass") + val UI_THEME = stringPreferencesKey("ui_theme") + val USE_BUILT_IN_SECURE_PASSWORD_KEYBOARD = + booleanPreferencesKey("use_built_in_secure_password_keyboard") val COURSE_REMINDER_ENABLED = booleanPreferencesKey("course_reminder_enabled") val COURSE_REMINDER_LIVE_COUNTDOWN_ENABLED = booleanPreferencesKey("course_reminder_live_countdown_enabled") @@ -44,12 +48,52 @@ object PreferencesKeys { val BEHAVIOR_RETENTION_DAYS = intPreferencesKey("behavior_retention_days") } +const val DEFAULT_THEME_COLOR = "default" + private val Context.dataStore by preferencesDataStore(name = "user_pref") class PreferencesManager @Inject constructor(@param:ApplicationContext private val context: Context) { + data class StartupThemePreferences( + val appUiTheme: AppUiTheme, + val themeColor: String?, + val themeMode: AppThemeMode + ) + + private val startupThemeMirror by lazy { + context.getSharedPreferences("startup_theme_mirror", Context.MODE_PRIVATE) + } + + fun getStartupThemePreferences(): StartupThemePreferences? { + if (!startupThemeMirror.getBoolean("initialized", false)) return null + return StartupThemePreferences( + appUiTheme = AppUiTheme.fromStorage( + startupThemeMirror.getString("ui_theme", null), + legacyUseLiquidGlass = null + ), + themeColor = startupThemeMirror.getString("theme_color", null), + themeMode = AppThemeMode.fromStorage( + startupThemeMirror.getString("theme_mode", null) + ) + ) + } + + fun rememberStartupThemePreferences( + appUiTheme: AppUiTheme, + themeColor: String?, + themeMode: AppThemeMode + ) { + startupThemeMirror.edit() + .putBoolean("initialized", true) + .putString("ui_theme", appUiTheme.storageValue) + .putString("theme_color", themeColor) + .putString("theme_mode", themeMode.storageValue) + .apply() + } + suspend fun clearAll() { context.dataStore.edit { preferences -> preferences.clear() } + startupThemeMirror.edit().clear().apply() } val personalizationEnabled: Flow = context.dataStore.data.map { prefs -> @@ -238,13 +282,33 @@ class PreferencesManager @Inject constructor(@param:ApplicationContext private v } } - val useLiquidGlass: Flow = context.dataStore.data.map { prefs -> - prefs[PreferencesKeys.USE_LIQUID_GLASS] ?: true + val appUiTheme: Flow = context.dataStore.data.map { prefs -> + AppUiTheme.fromStorage( + value = prefs[PreferencesKeys.UI_THEME], + legacyUseLiquidGlass = prefs[PreferencesKeys.USE_LIQUID_GLASS] + ) + } + + suspend fun setAppUiTheme(value: AppUiTheme) { + context.dataStore.edit { prefs -> + prefs[PreferencesKeys.UI_THEME] = value.storageValue + if (value == AppUiTheme.MIUIX) { + prefs[PreferencesKeys.THEME_COLOR] = DEFAULT_THEME_COLOR + } else if (prefs[PreferencesKeys.THEME_COLOR] == DEFAULT_THEME_COLOR) { + // "默认"是 Miuix 自己的蓝色,不应泄漏成 Material/LiquidGlass 的颜色。 + prefs.remove(PreferencesKeys.THEME_COLOR) + } + prefs.remove(PreferencesKeys.USE_LIQUID_GLASS) + } + } + + val useBuiltInSecurePasswordKeyboard: Flow = context.dataStore.data.map { prefs -> + prefs[PreferencesKeys.USE_BUILT_IN_SECURE_PASSWORD_KEYBOARD] ?: true } - suspend fun setUseLiquidGlass(value: Boolean) { + suspend fun setUseBuiltInSecurePasswordKeyboard(value: Boolean) { context.dataStore.edit { prefs -> - prefs[PreferencesKeys.USE_LIQUID_GLASS] = value + prefs[PreferencesKeys.USE_BUILT_IN_SECURE_PASSWORD_KEYBOARD] = value } } diff --git a/app/src/main/java/com/ahu/ahutong/data/mock_server/MockServer.kt b/app/src/main/java/com/ahu/ahutong/data/mock_server/MockServer.kt deleted file mode 100644 index dd301abf..00000000 --- a/app/src/main/java/com/ahu/ahutong/data/mock_server/MockServer.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.ahu.ahutong.data.mock_server - - -import com.ahu.ahutong.data.server.model.ApkUpdateInfo -import okhttp3.ResponseBody -import okhttp3.OkHttpClient -import retrofit2.Response -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory -import retrofit2.http.GET -import retrofit2.http.Path -import retrofit2.http.Url - -interface MockServer { - -// @POST("/ocr/captcha") -// @Multipart -// suspend fun getCaptchaResult(@Part data: MultipartBody.Part): Captcha - - @GET("/api/check_apk_update") - suspend fun getApkUpdateInfo(): ApkUpdateInfo - - @GET("/download/{filename}") - suspend fun downloadFile(@Path(value = "filename", encoded = true) filename: String): Response - - @GET - suspend fun downloadByUrl(@Url fileUrl: String): ResponseBody - - companion object { - val BASE_URL = "http://192.168.31.103:5000" - - - val okHttpClient = OkHttpClient - .Builder() - .followRedirects(true) - .followSslRedirects(true) - .build() - - - val API = Retrofit.Builder() - .addConverterFactory(GsonConverterFactory.create()) - .client(okHttpClient) - .baseUrl(BASE_URL) - .build().create(MockServer::class.java) - } -} diff --git a/app/src/main/java/com/ahu/ahutong/data/model/AppUiTheme.kt b/app/src/main/java/com/ahu/ahutong/data/model/AppUiTheme.kt new file mode 100644 index 00000000..f440c3e2 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/data/model/AppUiTheme.kt @@ -0,0 +1,13 @@ +package com.ahu.ahutong.data.model + +enum class AppUiTheme(val storageValue: String, val displayName: String) { + MATERIAL("material", "Material"), + MIUIX("miuix", "Miuix"), + LIQUID_GLASS("liquid_glass", "LiquidGlass"); + + companion object { + fun fromStorage(value: String?, legacyUseLiquidGlass: Boolean?): AppUiTheme = + entries.firstOrNull { it.storageValue == value } + ?: if (legacyUseLiquidGlass == false) MATERIAL else LIQUID_GLASS + } +} diff --git a/app/src/main/java/com/ahu/ahutong/data/model/CardRechargeBank.kt b/app/src/main/java/com/ahu/ahutong/data/model/CardRechargeBank.kt new file mode 100644 index 00000000..026b9e0a --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/data/model/CardRechargeBank.kt @@ -0,0 +1,12 @@ +package com.ahu.ahutong.data.model + +enum class CardRechargeBank(val storageValue: String) { + AGRICULTURAL_BANK("agricultural_bank"), + CHINA_MERCHANTS_BANK("china_merchants_bank"), + ALIPAY("alipay"); + + companion object { + fun fromStorage(value: String?): CardRechargeBank? = + entries.firstOrNull { it.storageValue == value } + } +} diff --git a/app/src/main/java/com/ahu/ahutong/data/model/ElectricityDepositHistoryItem.kt b/app/src/main/java/com/ahu/ahutong/data/model/ElectricityDepositHistoryItem.kt index e0157c6b..4e3c61a7 100644 --- a/app/src/main/java/com/ahu/ahutong/data/model/ElectricityDepositHistoryItem.kt +++ b/app/src/main/java/com/ahu/ahutong/data/model/ElectricityDepositHistoryItem.kt @@ -5,5 +5,7 @@ import java.io.Serializable data class ElectricityDepositHistoryItem( val selection: RoomSelectionInfo, val label: String, - val updatedAt: Long + val updatedAt: Long, + /** True only when the room was persisted after a confirmed successful payment. */ + val confirmedByPayment: Boolean = false ) : Serializable diff --git a/app/src/main/java/com/ahu/ahutong/data/model/EvaluationModels.kt b/app/src/main/java/com/ahu/ahutong/data/model/EvaluationModels.kt index 9da8e404..d1eb384a 100644 --- a/app/src/main/java/com/ahu/ahutong/data/model/EvaluationModels.kt +++ b/app/src/main/java/com/ahu/ahutong/data/model/EvaluationModels.kt @@ -4,7 +4,7 @@ import com.google.gson.annotations.SerializedName data class EvalApiResponse( val code: Int = 0, - val msg: String = "", + val msg: String? = null, val data: T? = null, val ok: Boolean = false ) diff --git a/app/src/main/java/com/ahu/ahutong/data/model/RoomSelectionInfo.kt b/app/src/main/java/com/ahu/ahutong/data/model/RoomSelectionInfo.kt index 6b5a8f36..f82c5b24 100644 --- a/app/src/main/java/com/ahu/ahutong/data/model/RoomSelectionInfo.kt +++ b/app/src/main/java/com/ahu/ahutong/data/model/RoomSelectionInfo.kt @@ -3,9 +3,24 @@ package com.ahu.ahutong.data.model import com.ahu.ahutong.ui.state.CampusDataItem import java.io.Serializable +enum class ElectricityController( + val displayName: String, + val feeItemId: String, + val requiresCampus: Boolean +) { + A("电控A", "408", false), + B("电控B", "428", false), + C("电控C", "488", true); + + val floorLevel: String get() = if (requiresCampus) "2" else "1" + val roomLevel: String get() = if (requiresCampus) "3" else "2" + val roomInfoLevel: String get() = if (requiresCampus) "4" else "3" +} + data class RoomSelectionInfo( val campus: CampusDataItem?, val building: CampusDataItem?, val floor: CampusDataItem?, - val room: CampusDataItem? -) : Serializable \ No newline at end of file + val room: CampusDataItem?, + val controller: ElectricityController? = null +) : Serializable diff --git a/app/src/main/java/com/ahu/ahutong/data/repository/RepositoryManager.kt b/app/src/main/java/com/ahu/ahutong/data/repository/RepositoryManager.kt index a1226e2f..1416308e 100644 --- a/app/src/main/java/com/ahu/ahutong/data/repository/RepositoryManager.kt +++ b/app/src/main/java/com/ahu/ahutong/data/repository/RepositoryManager.kt @@ -34,6 +34,25 @@ import okhttp3.Response import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody +internal object RepositoryIndexRefreshPolicy { + const val AUTO_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1_000L + + fun canReuse( + cachedAtMillis: Long, + cachedVersion: Int, + expectedVersion: Int, + hasRootContents: Boolean, + nowMillis: Long = System.currentTimeMillis() + ): Boolean { + val age = nowMillis - cachedAtMillis + return cachedAtMillis > 0L && + age >= 0L && + age < AUTO_REFRESH_INTERVAL_MS && + cachedVersion == expectedVersion && + hasRootContents + } +} + object RepositoryManager { private const val RAW_HOST = "https://raw.githubusercontent.com" private const val GITHUB_HOST = "https://github.com" @@ -145,6 +164,12 @@ object RepositoryManager { if (!forceRefresh) { getCachedContents(path)?.items?.let { return@withContext it } + + // The screen starts the full index warm-up independently. On a cold install the + // six repository trees should not block the first frame: the virtual repository + // roots are already known locally and can be rendered immediately while the + // detailed directory index is built on Dispatchers.IO. + fallbackRootItems?.let { return@withContext it } } runCatching { @@ -172,12 +197,14 @@ object RepositoryManager { warmUpMutex.withLock { val cachedUpdateTime = kv.decodeLong(CONTENT_TREE_CACHE_TIME_KEY, 0L) val cachedVersion = kv.decodeInt(CONTENT_TREE_CACHE_VERSION_KEY, 0) - // UI warm-up passes onProgress and should refresh remote trees even when cache exists. + val hasUsableFreshCache = RepositoryIndexRefreshPolicy.canReuse( + cachedAtMillis = cachedUpdateTime, + cachedVersion = cachedVersion, + expectedVersion = CONTENT_CACHE_VERSION, + hasRootContents = getCachedContents("") != null + ) if (!forceRefresh && - onProgress == null && - cachedUpdateTime > 0L && - cachedVersion == CONTENT_CACHE_VERSION && - getCachedContents("") != null + hasUsableFreshCache ) { return@withLock cachedUpdateTime } @@ -529,7 +556,6 @@ object RepositoryManager { source: RepositorySource, tree: List ): RepositoryIndexCache { - val resolvedLfsSizes = resolveGitLfsDisplaySizes(source, tree) val allChildren = mutableMapOf>() val allDirectories = mutableSetOf("") @@ -565,7 +591,10 @@ object RepositoryManager { name = childName, path = virtualPath(source, childPath), type = "file", - size = resolvedLfsSizes[childPath] ?: child.size, + // GitHub's recursive tree already contains the index metadata. Do not + // fetch every small file just to detect an LFS pointer; the actual LFS + // size is resolved lazily when that file is opened or downloaded. + size = child.size, downloadUrl = source.rawUrl(childPath), htmlUrl = source.githubUrl(childPath, tree = false), repositoryId = source.id, @@ -756,34 +785,6 @@ object RepositoryManager { return accelerationSources.firstOrNull { it.id == selectedId } ?: accelerationSources.first() } - private fun resolveGitLfsDisplaySizes( - source: RepositorySource, - tree: List - ): Map { - val candidatePaths = tree.asSequence() - .filter { it.type == "blob" } - .filter { it.size in 1..GIT_LFS_POINTER_MAX_BYTES.toLong() } - .map { normalizeRepositoryPath(it.path) } - .filter { it.isNotEmpty() && isDocumentFile(it.substringAfterLast('/')) } - .toList() - - if (candidatePaths.isEmpty()) return emptyMap() - - return candidatePaths.mapNotNull { repositoryPath -> - val request = Request.Builder() - .url(source.rawUrl(repositoryPath)) - .header("User-Agent", "AHUTong-Android") - .build() - val size = runCatching { - downloadClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) return@use null - response.readGitLfsPointer()?.size - } - }.getOrNull() - size?.let { repositoryPath to it } - }.toMap() - } - private fun isDocumentFile(name: String): Boolean { val lower = name.lowercase() return lower.endsWith(".pdf") || lower.endsWith(".doc") || diff --git a/app/src/main/java/com/ahu/ahutong/data/security/SecureStorage.kt b/app/src/main/java/com/ahu/ahutong/data/security/SecureStorage.kt new file mode 100644 index 00000000..0c8b5522 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/data/security/SecureStorage.kt @@ -0,0 +1,113 @@ +package com.ahu.ahutong.data.security + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import android.util.Log +import com.ahu.ahutong.AHUApplication +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Small fail-closed AES-GCM store backed by a non-exportable Android Keystore key. + * Only ciphertext, IVs and version metadata are written to SharedPreferences. + */ +object SecureStorage { + private const val TAG = "SecureStorage" + private const val KEY_ALIAS = "ahutong.secure-storage.v1" + private const val PREFS_NAME = "secure_storage_v1" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val VERSION = "v1" + private const val TAG_LENGTH_BITS = 128 + + private val preferences by lazy { + AHUApplication.getApp().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + @Synchronized + fun putString(key: String, value: String) { + if (value.isEmpty()) { + remove(key) + return + } + val cipher = Cipher.getInstance(TRANSFORMATION).apply { + init(Cipher.ENCRYPT_MODE, getOrCreateKey()) + } + val ciphertext = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + val encoded = listOf( + VERSION, + Base64.encodeToString(cipher.iv, Base64.NO_WRAP), + Base64.encodeToString(ciphertext, Base64.NO_WRAP) + ).joinToString(":") + check(preferences.edit().putString(key, encoded).commit()) { + "Failed to persist encrypted value" + } + } + + @Synchronized + fun getString(key: String): String? { + val encoded = preferences.getString(key, null) ?: return null + return try { + val parts = encoded.split(':', limit = 3) + require(parts.size == 3 && parts[0] == VERSION) { "Unsupported ciphertext" } + val iv = Base64.decode(parts[1], Base64.NO_WRAP) + val ciphertext = Base64.decode(parts[2], Base64.NO_WRAP) + val cipher = Cipher.getInstance(TRANSFORMATION).apply { + init( + Cipher.DECRYPT_MODE, + getOrCreateKey(), + GCMParameterSpec(TAG_LENGTH_BITS, iv) + ) + } + cipher.doFinal(ciphertext).toString(Charsets.UTF_8) + } catch (e: Exception) { + // A replaced/invalidated key must never make us fall back to treating ciphertext as data. + Log.w(TAG, "Unable to decrypt stored value; removing it", e) + preferences.edit().remove(key).commit() + null + } + } + + @Synchronized + fun remove(key: String) { + preferences.edit().remove(key).commit() + } + + @Synchronized + fun entries(prefix: String): Map = + preferences.all.keys + .asSequence() + .filter { it.startsWith(prefix) } + .mapNotNull { key -> getString(key)?.let { value -> key to value } } + .toMap() + + @Synchronized + fun clearPrefix(prefix: String) { + val editor = preferences.edit() + preferences.all.keys.filter { it.startsWith(prefix) }.forEach(editor::remove) + editor.commit() + } + + private fun getOrCreateKey(): SecretKey { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build() + ) + generateKey() + } + } +} diff --git a/app/src/main/java/com/ahu/ahutong/data/server/AhuTong.kt b/app/src/main/java/com/ahu/ahutong/data/server/AhuTong.kt index 65eb2c83..9247060d 100644 --- a/app/src/main/java/com/ahu/ahutong/data/server/AhuTong.kt +++ b/app/src/main/java/com/ahu/ahutong/data/server/AhuTong.kt @@ -94,5 +94,9 @@ interface AhuTong { val API = createApi(okHttpClient) val APK_DOWNLOAD_API = createApi(apkDownloadOkHttpClient) val GRAY_API = createApi(grayOkHttpClient) + + fun cancelApkDownloads() { + apkDownloadOkHttpClient.dispatcher.cancelAll() + } } } diff --git a/app/src/main/java/com/ahu/ahutong/data/weather/WeatherApi.kt b/app/src/main/java/com/ahu/ahutong/data/weather/WeatherApi.kt index c6e6efde..84ff37d4 100644 --- a/app/src/main/java/com/ahu/ahutong/data/weather/WeatherApi.kt +++ b/app/src/main/java/com/ahu/ahutong/data/weather/WeatherApi.kt @@ -1,5 +1,6 @@ package com.ahu.ahutong.data.weather +import com.ahu.ahutong.BuildConfig import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit @@ -23,13 +24,18 @@ interface WeatherApi { companion object { private val loggingInterceptor = HttpLoggingInterceptor().apply { + redactHeader("Authorization") + redactHeader("Cookie") + redactHeader("Set-Cookie") level = HttpLoggingInterceptor.Level.BASIC } private val okHttpClient = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) - .addInterceptor(loggingInterceptor) + .apply { + if (BuildConfig.DEBUG) addInterceptor(loggingInterceptor) + } .build() val API: WeatherApi = Retrofit.Builder() diff --git a/app/src/main/java/com/ahu/ahutong/notification/CourseLiveUpdateHelper.kt b/app/src/main/java/com/ahu/ahutong/notification/CourseLiveUpdateHelper.kt index 073034b6..63a6d8cf 100644 --- a/app/src/main/java/com/ahu/ahutong/notification/CourseLiveUpdateHelper.kt +++ b/app/src/main/java/com/ahu/ahutong/notification/CourseLiveUpdateHelper.kt @@ -1,11 +1,14 @@ package com.ahu.ahutong.notification +import android.Manifest import android.app.AlarmManager import android.app.Notification import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.os.Build +import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.TaskStackBuilder @@ -59,6 +62,15 @@ object CourseLiveUpdateHelper { .build() if (!hasPromotableCharacteristics(notification)) return false + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + return false + } NotificationManagerCompat.from(context).notify(LIVE_NOTIFICATION_ID, notification) return true diff --git a/app/src/main/java/com/ahu/ahutong/notification/CourseReminderBootReceiver.kt b/app/src/main/java/com/ahu/ahutong/notification/CourseReminderBootReceiver.kt index 2bec361a..9e3e306b 100644 --- a/app/src/main/java/com/ahu/ahutong/notification/CourseReminderBootReceiver.kt +++ b/app/src/main/java/com/ahu/ahutong/notification/CourseReminderBootReceiver.kt @@ -11,7 +11,10 @@ class CourseReminderBootReceiver : BroadcastReceiver() { Intent.ACTION_MY_PACKAGE_REPLACED, Intent.ACTION_TIME_CHANGED, Intent.ACTION_TIMEZONE_CHANGED -> { - CourseReminderScheduler.reschedule(context) + val pendingResult = goAsync() + CourseReminderScheduler.reschedule(context).invokeOnCompletion { + pendingResult.finish() + } } } } diff --git a/app/src/main/java/com/ahu/ahutong/notification/CourseReminderCapability.kt b/app/src/main/java/com/ahu/ahutong/notification/CourseReminderCapability.kt index d15da417..0c9c66fe 100644 --- a/app/src/main/java/com/ahu/ahutong/notification/CourseReminderCapability.kt +++ b/app/src/main/java/com/ahu/ahutong/notification/CourseReminderCapability.kt @@ -10,15 +10,14 @@ import androidx.core.app.NotificationManagerCompat import com.ahu.ahutong.data.dao.PreferencesManager import com.ahu.ahutong.notification.model.CourseReminderPayload import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking object CourseReminderCapability { private const val ANDROID_16_API = 36 fun isAndroid16Plus(): Boolean = Build.VERSION.SDK_INT >= ANDROID_16_API - fun isLiveCountdownEnabled(context: Context): Boolean = runBlocking { - PreferencesManager(context).courseReminderLiveCountdownEnabled.first() + suspend fun isLiveCountdownEnabled(context: Context): Boolean { + return PreferencesManager(context).courseReminderLiveCountdownEnabled.first() } fun canUsePromotedNotifications(context: Context): Boolean { @@ -38,7 +37,7 @@ object CourseReminderCapability { return notificationManager.canPostPromotedNotifications() } - fun shouldTryLiveCountdown( + suspend fun shouldTryLiveCountdown( context: Context, payload: CourseReminderPayload ): Boolean { diff --git a/app/src/main/java/com/ahu/ahutong/notification/CourseReminderNotifier.kt b/app/src/main/java/com/ahu/ahutong/notification/CourseReminderNotifier.kt index 6f1ad271..4a405d69 100644 --- a/app/src/main/java/com/ahu/ahutong/notification/CourseReminderNotifier.kt +++ b/app/src/main/java/com/ahu/ahutong/notification/CourseReminderNotifier.kt @@ -15,7 +15,7 @@ import com.ahu.ahutong.R import com.ahu.ahutong.notification.model.CourseReminderPayload object CourseReminderNotifier { - fun showReminder( + suspend fun showReminder( context: Context, payload: CourseReminderPayload ): Boolean { @@ -68,7 +68,13 @@ object CourseReminderNotifier { .setContentIntent(buildContentIntent(context, payload.notificationId)) .build() - NotificationManagerCompat.from(context).notify(payload.notificationId, notification) + if (!canPostNotifications(context)) return + try { + NotificationManagerCompat.from(context).notify(payload.notificationId, notification) + } catch (_: SecurityException) { + // Permission can be revoked between the explicit check and the notify call. + return + } } private fun canPostNotifications(context: Context): Boolean { diff --git a/app/src/main/java/com/ahu/ahutong/notification/CourseReminderReceiver.kt b/app/src/main/java/com/ahu/ahutong/notification/CourseReminderReceiver.kt index d5e8dd29..90af6a1c 100644 --- a/app/src/main/java/com/ahu/ahutong/notification/CourseReminderReceiver.kt +++ b/app/src/main/java/com/ahu/ahutong/notification/CourseReminderReceiver.kt @@ -4,6 +4,10 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import com.ahu.ahutong.notification.model.CourseReminderPayload +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch class CourseReminderReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -13,32 +17,41 @@ class CourseReminderReceiver : BroadcastReceiver() { } val payload = CourseReminderPayload.fromIntent(intent) ?: return - when (intent.action) { - ACTION_REMIND -> { - val liveUpdateShown = CourseReminderNotifier.showReminder(context, payload) - if (liveUpdateShown) { - CourseLiveUpdateHelper.scheduleNextUpdate(context, payload) - } - CourseReminderScheduler.reschedule(context) - } + val pendingResult = goAsync() + receiverScope.launch { + try { + when (intent.action) { + ACTION_REMIND -> { + val liveUpdateShown = CourseReminderNotifier.showReminder(context, payload) + if (liveUpdateShown) { + CourseLiveUpdateHelper.scheduleNextUpdate(context, payload) + } + CourseReminderScheduler.reschedule(context).join() + } - ACTION_UPDATE_LIVE_COUNTDOWN -> { - if (!CourseReminderCapability.shouldTryLiveCountdown(context, payload)) { - CourseReminderNotifier.cancelActiveReminder(context) - return - } + ACTION_UPDATE_LIVE_COUNTDOWN -> { + if (!CourseReminderCapability.shouldTryLiveCountdown(context, payload)) { + CourseReminderNotifier.cancelActiveReminder(context) + return@launch + } - val liveUpdateShown = CourseLiveUpdateHelper.showLiveUpdate(context, payload) - if (liveUpdateShown) { - CourseLiveUpdateHelper.scheduleNextUpdate(context, payload) - } else { - CourseReminderNotifier.cancelActiveReminder(context) + val liveUpdateShown = CourseLiveUpdateHelper.showLiveUpdate(context, payload) + if (liveUpdateShown) { + CourseLiveUpdateHelper.scheduleNextUpdate(context, payload) + } else { + CourseReminderNotifier.cancelActiveReminder(context) + } + } } + } finally { + pendingResult.finish() } } } companion object { + private val receiverScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + const val ACTION_REMIND = "com.ahu.ahutong.notification.ACTION_REMIND_COURSE" const val ACTION_UPDATE_LIVE_COUNTDOWN = "com.ahu.ahutong.notification.ACTION_UPDATE_LIVE_COUNTDOWN" diff --git a/app/src/main/java/com/ahu/ahutong/notification/CourseReminderScheduler.kt b/app/src/main/java/com/ahu/ahutong/notification/CourseReminderScheduler.kt index 00757f02..8f58a3c0 100644 --- a/app/src/main/java/com/ahu/ahutong/notification/CourseReminderScheduler.kt +++ b/app/src/main/java/com/ahu/ahutong/notification/CourseReminderScheduler.kt @@ -18,10 +18,18 @@ import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime import java.time.ZoneId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock object CourseReminderScheduler { + private val schedulerScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val rescheduleMutex = Mutex() internal const val CHANNEL_ID = "course_reminder_v2" private const val CHANNEL_NAME = "课前提醒" @@ -51,7 +59,13 @@ object CourseReminderScheduler { manager.createNotificationChannel(channel) } - fun reschedule(context: Context) { + fun reschedule(context: Context): Job = schedulerScope.launch { + rescheduleMutex.withLock { + rescheduleNow(context.applicationContext) + } + } + + private suspend fun rescheduleNow(context: Context) { cancelScheduledReminder(context) if (!isReminderEnabled(context)) return @@ -71,11 +85,10 @@ object CourseReminderScheduler { fun scheduleDebugReminder(context: Context, delayMinutes: Int) { createNotificationChannel(context) - val triggerAtMillis = System.currentTimeMillis() + delayMinutes * 10_000L - val triggerDelaySeconds = delayMinutes * 10 + val triggerAtMillis = System.currentTimeMillis() + delayMinutes * 60_000L val payload = CourseReminderPayload( courseName = "课前提醒测试", - location = "预计 $triggerDelaySeconds 秒后触发", + location = "预计 $delayMinutes 分钟后触发", timeText = "调试通知", notificationId = DEBUG_REQUEST_CODE_BASE + delayMinutes, allowLiveCountdown = false @@ -94,7 +107,7 @@ object CourseReminderScheduler { fun scheduleDebugLiveUpdateReminder(context: Context, delayMinutes: Int) { createNotificationChannel(context) - val triggerAtMillis = System.currentTimeMillis() + delayMinutes * 10_000L + val triggerAtMillis = System.currentTimeMillis() + delayMinutes * 60_000L val payload = CourseReminderPayload( courseName = "课前岛卡测试", location = "调试入口", @@ -180,11 +193,8 @@ object CourseReminderScheduler { } } - private fun isReminderEnabled(context: Context): Boolean { - return runBlocking { - PreferencesManager(context).courseReminderEnabled.first() - } - } + private suspend fun isReminderEnabled(context: Context): Boolean = + PreferencesManager(context).courseReminderEnabled.first() private fun buildPendingIntent( context: Context, diff --git a/app/src/main/java/com/ahu/ahutong/personalization/action/AppAction.kt b/app/src/main/java/com/ahu/ahutong/personalization/action/AppAction.kt index ad73b267..b80edce7 100644 --- a/app/src/main/java/com/ahu/ahutong/personalization/action/AppAction.kt +++ b/app/src/main/java/com/ahu/ahutong/personalization/action/AppAction.kt @@ -202,6 +202,9 @@ object AppActionCatalog { private val specById = specs.associateBy(AppActionSpec::id) private val specByRoute = specs.mapNotNull { value -> value.route?.let { it to value } }.toMap() + private val routeAliases = mapOf( + "electricity_recent_rooms" to AppActionId.OPEN_ELECTRICITY_PAYMENT + ) private val commandRoutePrefixes: Map> = mapOf( AppActionId.OPEN_PAYMENT_QR to setOf("home"), AppActionId.REFRESH_PAYMENT_QR to setOf("home"), @@ -210,7 +213,7 @@ object AppActionCatalog { AppActionId.CONFIRM_BATHROOM_PAYMENT to setOf("bathroom_deposit"), AppActionId.CONFIRM_ELECTRICITY_PAYMENT to setOf("electricity_pay"), AppActionId.SUBMIT_CARD_RECHARGE to setOf("card_balance_deposit"), - AppActionId.SUBMIT_CMB_CARD_RECHARGE to setOf("cmb_card_recharge"), + AppActionId.SUBMIT_CMB_CARD_RECHARGE to setOf("card_balance_deposit", "cmb_card_recharge"), AppActionId.SUBMIT_NETWORK_RECHARGE to setOf("network_recharge"), AppActionId.EDIT_HOME to setOf("home"), AppActionId.MANUAL_REFRESH_SCHEDULE to setOf("schedule"), @@ -280,6 +283,7 @@ object AppActionCatalog { "repository", "repository/{path}", "repository_downloads", "repository_settings", "settings", "settings__license", "settings__contributors", "preferences", "electricity_pay", "card_balance_deposit", "bathroom_deposit", "cmb_card_recharge", "network_recharge", + "electricity_recent_rooms", "splash" ) @@ -301,6 +305,7 @@ object AppActionCatalog { fun actionForRoute(route: String?): AppActionId? { if (route == null) return null specByRoute[route]?.let { return it.id } + routeAliases[route]?.let { return it } if (route.startsWith("repository/")) return AppActionId.OPEN_REPOSITORY_DIRECTORY return null } diff --git a/app/src/main/java/com/ahu/ahutong/personalization/bootstrap/BootstrapTrainingModels.kt b/app/src/main/java/com/ahu/ahutong/personalization/bootstrap/BootstrapTrainingModels.kt index 6753d985..ea123860 100644 --- a/app/src/main/java/com/ahu/ahutong/personalization/bootstrap/BootstrapTrainingModels.kt +++ b/app/src/main/java/com/ahu/ahutong/personalization/bootstrap/BootstrapTrainingModels.kt @@ -1,5 +1,6 @@ package com.ahu.ahutong.personalization.bootstrap +import com.ahu.ahutong.personalization.journey.JourneyTrainingLabelPolicy import com.ahu.ahutong.personalization.action.AppActionCatalog import com.ahu.ahutong.personalization.context.FeatureExtractor import com.ahu.ahutong.personalization.journey.JourneyGoalCatalog @@ -228,8 +229,9 @@ const val MAX_EXAMPLES_PER_BATCH = 256 internal val LOWER_SHA256 = Regex("^[0-9a-f]{64}$") private val FEEDBACK_WEIGHTS = mapOf( "ORGANIC_ACTION" to 1f, - "INTERVENTION_FREE_TIMEOUT" to 1f, - "ORGANIC_JOURNEY" to 1f, + JourneyTrainingLabelPolicy.INTERVENTION_FREE_TIMEOUT to 1f, + JourneyTrainingLabelPolicy.INTERVENTION_FREE_MAX_STEPS to 1f, + JourneyTrainingLabelPolicy.ORGANIC_JOURNEY to 1f, "NATURAL_COMMIT" to 1f, "SUGGESTION_ACCEPTED" to 0.25f, "ASSISTED_QUERY_CONFIRMED" to 0.20f, @@ -237,7 +239,7 @@ private val FEEDBACK_WEIGHTS = mapOf( "ASSISTED_REMOVED" to 0.10f ) private val NEXT_ACTION_FEEDBACK = setOf("ORGANIC_ACTION", "INTERVENTION_FREE_TIMEOUT", "SUGGESTION_ACCEPTED") -private val JOURNEY_FEEDBACK = setOf("ORGANIC_JOURNEY", "INTERVENTION_FREE_TIMEOUT") +private val JOURNEY_FEEDBACK = JourneyTrainingLabelPolicy.supportedSources private val PRESET_FEEDBACK = setOf( "NATURAL_COMMIT", "ASSISTED_QUERY_CONFIRMED", "ASSISTED_REPLACED", "ASSISTED_REMOVED" ) diff --git a/app/src/main/java/com/ahu/ahutong/personalization/journey/JourneyOnDeviceTrainer.kt b/app/src/main/java/com/ahu/ahutong/personalization/journey/JourneyOnDeviceTrainer.kt index b75f8bb9..ccecafa8 100644 --- a/app/src/main/java/com/ahu/ahutong/personalization/journey/JourneyOnDeviceTrainer.kt +++ b/app/src/main/java/com/ahu/ahutong/personalization/journey/JourneyOnDeviceTrainer.kt @@ -1,5 +1,6 @@ package com.ahu.ahutong.personalization.journey +import android.util.Log import com.ahu.ahutong.personalization.inference.AdamWState import com.ahu.ahutong.personalization.bootstrap.BootstrapTrainingDataManager import com.ahu.ahutong.personalization.inference.TinyMlpBackprop @@ -26,6 +27,20 @@ data class JourneyTrainingSliceResult( val reason: String ) +internal object JourneyTrainingLabelPolicy { + const val ORGANIC_JOURNEY = "ORGANIC_JOURNEY" + const val INTERVENTION_FREE_TIMEOUT = "INTERVENTION_FREE_TIMEOUT" + const val INTERVENTION_FREE_MAX_STEPS = "INTERVENTION_FREE_MAX_STEPS" + + val supportedSources = setOf( + ORGANIC_JOURNEY, + INTERVENTION_FREE_TIMEOUT, + INTERVENTION_FREE_MAX_STEPS + ) + + fun accepts(labelSource: String): Boolean = labelSource in supportedSources +} + @Singleton class JourneyOnDeviceTrainer @Inject constructor( private val dao: BehaviorDao, @@ -39,7 +54,12 @@ class JourneyOnDeviceTrainer @Inject constructor( private val cancelledGenerations = ConcurrentHashMap() suspend fun enqueue(sample: JourneyTrainingSampleEntity) { - require(sample.labelSource == "ORGANIC_JOURNEY" || sample.labelSource == "INTERVENTION_FREE_TIMEOUT") + if (!JourneyTrainingLabelPolicy.accepts(sample.labelSource)) { + // Personalization is ancillary. A malformed training label must never terminate the + // user-facing flow (for example, login navigation) from a background coroutine. + Log.w(TAG, "Ignoring unsupported journey training label: ${sample.labelSource}") + return + } val inserted = dao.insertJourneyTrainingSample(sample) if (inserted != -1L) runCatching { bootstrapTrainingDataManager?.captureJourney(sample) @@ -172,6 +192,7 @@ class JourneyOnDeviceTrainer @Inject constructor( private fun AdamWState.deepCopy() = AdamWState(firstMoments.map(FloatArray::copyOf), secondMoments.map(FloatArray::copyOf), step) private companion object { + const val TAG = "JourneyTrainer" const val MIN_SAMPLES = 128 const val MIN_NON_NONE = 64 const val MIN_FAMILIES = 3 diff --git a/app/src/main/java/com/ahu/ahutong/personalization/journey/JourneyPredictionEngine.kt b/app/src/main/java/com/ahu/ahutong/personalization/journey/JourneyPredictionEngine.kt index dd555b11..4061f03c 100644 --- a/app/src/main/java/com/ahu/ahutong/personalization/journey/JourneyPredictionEngine.kt +++ b/app/src/main/java/com/ahu/ahutong/personalization/journey/JourneyPredictionEngine.kt @@ -1,5 +1,6 @@ package com.ahu.ahutong.personalization.journey +import android.util.Log import com.ahu.ahutong.personalization.action.ActionSource import com.ahu.ahutong.personalization.action.AppActionCatalog import com.ahu.ahutong.personalization.action.AppActionId @@ -23,12 +24,14 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.math.ln import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -50,7 +53,11 @@ class JourneyPredictionEngine @Inject constructor( private val modelStore: JourneyModelStateStore, private val telemetryAggregateStore: TelemetryAggregateStore ) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val scope = CoroutineScope( + SupervisorJob() + Dispatchers.Default + CoroutineExceptionHandler { _, error -> + Log.e(TAG, "Background journey task failed", error) + } + ) private val locks = ConcurrentHashMap() private val deadlineJobs = ConcurrentHashMap() private val dwellJobs = ConcurrentHashMap() @@ -139,7 +146,12 @@ class JourneyPredictionEngine @Inject constructor( val path = pending.observedActionIdsCsv.split(',').filter(String::isNotBlank) + action.stableId val count = pending.observedActionCount + 1 if (count > pending.maximumActions) { - resolve(pending, JourneyGoalCatalog.NONE_OUTPUT_ID, eventId, "INTERVENTION_FREE_MAX_STEPS") + resolve( + pending, + JourneyGoalCatalog.NONE_OUTPUT_ID, + eventId, + JourneyTrainingLabelPolicy.INTERVENTION_FREE_MAX_STEPS + ) return@withLock } val updated = pending.copy( @@ -151,16 +163,28 @@ class JourneyPredictionEngine @Inject constructor( ) dao.updatePendingJourney(updated) when { - JourneyGoalCatalog.isImmediateMilestone(action) -> resolve(updated, action.stableId, eventId, "ORGANIC_JOURNEY") + JourneyGoalCatalog.isImmediateMilestone(action) -> resolve( + updated, + action.stableId, + eventId, + JourneyTrainingLabelPolicy.ORGANIC_JOURNEY + ) JourneyGoalCatalog.isSafeTerminal(action) -> scheduleDwell(updated, action, eventId) - count == pending.maximumActions -> resolve(updated, JourneyGoalCatalog.NONE_OUTPUT_ID, eventId, "INTERVENTION_FREE_MAX_STEPS") + count == pending.maximumActions -> resolve( + updated, + JourneyGoalCatalog.NONE_OUTPUT_ID, + eventId, + JourneyTrainingLabelPolicy.INTERVENTION_FREE_MAX_STEPS + ) } } suspend fun onExplicitMilestone(profileKey: String, target: AppActionId, eventId: String) = locks.getOrPut(profileKey) { Mutex() }.withLock { if (!JourneyGoalCatalog.isSafeTerminal(target)) return@withLock - dao.latestPendingJourney(profileKey)?.let { resolve(it, target.stableId, eventId, "ORGANIC_JOURNEY") } + dao.latestPendingJourney(profileKey)?.let { + resolve(it, target.stableId, eventId, JourneyTrainingLabelPolicy.ORGANIC_JOURNEY) + } } suspend fun censorProfile(profileKey: String, reason: String) = locks.getOrPut(profileKey) { Mutex() }.withLock { @@ -197,26 +221,36 @@ class JourneyPredictionEngine @Inject constructor( private fun scheduleDwell(pending: PendingJourneyEntity, action: AppActionId, eventId: String) { dwellJobs.remove(pending.journeyId)?.cancel() - dwellJobs[pending.journeyId] = scope.async { + dwellJobs[pending.journeyId] = scope.launch { delay(LEAF_DWELL_MS) locks.getOrPut(pending.profileKey) { Mutex() }.withLock { val current = dao.pendingJourney(pending.journeyId) ?: return@withLock if (current.resolutionStatus == "PENDING" && current.lastLeafActionId == action.stableId && current.lastLeafEventId == eventId - ) resolve(current, action.stableId, eventId, "ORGANIC_JOURNEY") + ) resolve( + current, + action.stableId, + eventId, + JourneyTrainingLabelPolicy.ORGANIC_JOURNEY + ) } } } private fun scheduleDeadline(pending: PendingJourneyEntity) { deadlineJobs.remove(pending.journeyId)?.cancel() - deadlineJobs[pending.journeyId] = scope.async { + deadlineJobs[pending.journeyId] = scope.launch { val remaining = (pending.deadlineElapsedMs - android.os.SystemClock.elapsedRealtime()).coerceAtLeast(0) delay(remaining + 250) locks.getOrPut(pending.profileKey) { Mutex() }.withLock { val current = dao.pendingJourney(pending.journeyId) ?: return@withLock if (current.resolutionStatus == "PENDING" && current.interventionState == "NONE") { - resolve(current, JourneyGoalCatalog.NONE_OUTPUT_ID, UUID.randomUUID().toString(), "INTERVENTION_FREE_TIMEOUT") + resolve( + current, + JourneyGoalCatalog.NONE_OUTPUT_ID, + UUID.randomUUID().toString(), + JourneyTrainingLabelPolicy.INTERVENTION_FREE_TIMEOUT + ) } } } @@ -352,6 +386,7 @@ class JourneyPredictionEngine @Inject constructor( } private companion object { + const val TAG = "JourneyPrediction" const val JOURNEY_WINDOW_MS = 120_000L const val MAX_ACTIONS = 5 const val LEAF_DWELL_MS = 4_000L diff --git a/app/src/main/java/com/ahu/ahutong/personalization/runtime/PredictionRuntime.kt b/app/src/main/java/com/ahu/ahutong/personalization/runtime/PredictionRuntime.kt index 3e2d4a26..d32a07a0 100644 --- a/app/src/main/java/com/ahu/ahutong/personalization/runtime/PredictionRuntime.kt +++ b/app/src/main/java/com/ahu/ahutong/personalization/runtime/PredictionRuntime.kt @@ -5,6 +5,7 @@ import android.os.BatteryManager import android.os.Build import android.os.PowerManager import android.os.SystemClock +import android.util.Log import com.ahu.ahutong.BuildConfig import com.ahu.ahutong.personalization.action.ActionFamily import com.ahu.ahutong.personalization.action.ActionSource @@ -99,6 +100,7 @@ import javax.crypto.spec.SecretKeySpec import kotlin.math.abs import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -224,7 +226,16 @@ class BehaviorPredictionRuntime @Inject constructor( private val journeyEngine: JourneyPredictionEngine, private val presetRankingEngine: PresetRankingEngine ) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val scope = CoroutineScope( + SupervisorJob() + Dispatchers.Default + CoroutineExceptionHandler { _, error -> + if (error !is CancellationException) { + Log.e(TAG, "Background prediction task failed", error) + _diagnostics.value = _diagnostics.value.copy( + lastFailure = "BACKGROUND_TASK_FAILED_${error::class.java.simpleName}" + ) + } + } + ) private val processInstanceId = UUID.randomUUID().toString() private val profileLifecycleMutex = Mutex() private val profileLocks = ConcurrentHashMap() @@ -2666,6 +2677,7 @@ class BehaviorPredictionRuntime @Inject constructor( ) private companion object { + const val TAG = "PredictionRuntime" const val LABEL_WINDOW_POLICY_VERSION = 1 const val CONTEXT_DEBOUNCE_MS = 30_000L const val SEMANTIC_CHANGE_SET_WINDOW_MS = 5_000L diff --git a/app/src/main/java/com/ahu/ahutong/sdk/RustSDK.kt b/app/src/main/java/com/ahu/ahutong/sdk/RustSDK.kt index 76a96b93..315ee023 100644 --- a/app/src/main/java/com/ahu/ahutong/sdk/RustSDK.kt +++ b/app/src/main/java/com/ahu/ahutong/sdk/RustSDK.kt @@ -1,6 +1,7 @@ package com.ahu.ahutong.sdk import android.content.Context +import android.graphics.BitmapFactory import android.util.Log import com.ahu.ahutong.BuildConfig import com.ahu.ahutong.data.model.Card @@ -25,6 +26,8 @@ import android.provider.MediaStore import android.os.Build import android.os.Environment import java.io.FileInputStream +import java.nio.file.Files +import java.nio.file.StandardCopyOption import kotlin.system.exitProcess import org.conscrypt.Conscrypt import java.security.Security @@ -165,16 +168,8 @@ object RustSDK { val prefs = context.getSharedPreferences("rust_sdk_config", Context.MODE_PRIVATE) val currentVersion = prefs.getInt("so_version", 301) - // Get original URL and Host from SDK val originalConfigUrl = getUpdateConfigUrl() - val originalHost = try { URL(originalConfigUrl).host } catch (e: Exception) { - Log.w(TAG_HOTUPDATE, "Failed to parse host from config url", e) - "" - } - val serverIp = getApiServerIp() - - // Construct IP-based URL by replacing host - val configUrl = originalConfigUrl.replace(originalHost, serverIp) + val configUrl = URL(originalConfigUrl).also(::requireTrustedUpdateUrl).toString() Log.i( TAG_HOTUPDATE, @@ -186,9 +181,9 @@ object RustSDK { val startMs = System.currentTimeMillis() val jsonStr: String = try { val url = URL(configUrl) - val conn = (url.openConnection() as java.net.HttpURLConnection).apply { + val conn = (url.openConnection() as javax.net.ssl.HttpsURLConnection).apply { - instanceFollowRedirects = true + instanceFollowRedirects = false connectTimeout = 5000 readTimeout = 5000 requestMethod = "GET" @@ -199,16 +194,6 @@ object RustSDK { setRequestProperty("Accept", "application/json") setRequestProperty("Connection", "close") - if (this is javax.net.ssl.HttpsURLConnection) { - try { - this.sslSocketFactory = getConscryptSocketFactory() - this.hostnameVerifier = javax.net.ssl.HostnameVerifier { hostname, session -> - if (hostname == serverIp) true else javax.net.ssl.HttpsURLConnection.getDefaultHostnameVerifier().verify(hostname, session) - } - } catch (e: Exception) { - Log.w(TAG_HOTUPDATE, "Failed to set Conscrypt factory", e) - } - } } // 触发真正连接/请求 @@ -268,10 +253,8 @@ object RustSDK { // 3) 解析 JSON(带保护日志) val config: UpdateConfig = try { - Gson().fromJson(jsonStr, UpdateConfig::class.java).let { - // Replace domain with IP for download url - it.copy(url = it.url.replace(originalHost, serverIp)) - }.also { + Gson().fromJson(jsonStr, UpdateConfig::class.java).also { + requireTrustedUpdateUrl(URL(it.url)) Log.i( TAG_HOTUPDATE, "checkUpdate parsed config ok. remoteVersion=${it.version}, soUrl=${it.url.take(200)}" @@ -566,7 +549,10 @@ object RustSDK { val dir = File(context.filesDir, "images") if (!dir.exists()) dir.mkdirs() val file = File(dir, "xiaoli.jpg") - return if (file.exists()) file else null + return if (isValidCalendarImage(file)) file else { + if (file.exists()) file.delete() + null + } } suspend fun fetchSchoolCalendar(context: Context, onProgress: (Float) -> Unit): File? { @@ -577,7 +563,7 @@ object RustSDK { if (!dir.exists()) dir.mkdirs() val saveFile = File(dir, "xiaoli.jpg") - if (saveFile.exists()) { + if (isValidCalendarImage(saveFile)) { Log.d("RustSDK", "Found cached calendar: ${saveFile.absolutePath}") onProgress(1.0f) return@withContext saveFile @@ -587,7 +573,7 @@ object RustSDK { // Use Kotlin implementation to bypass SNI block val success = downloadSchoolCalendarKotlin(saveFile.absolutePath, onProgress) Log.d("RustSDK", "Download result: $success") - if (success && saveFile.exists()) { + if (success && isValidCalendarImage(saveFile)) { saveFile } else { null @@ -600,23 +586,26 @@ object RustSDK { } private fun downloadSchoolCalendarKotlin(savePath: String, onProgress: (Float) -> Unit): Boolean { - val serverIp = getApiServerIp() - val urlStr = "https://$serverIp/download/xiaoli.jpg" + val urlStr = "https://openahu.org/download/xiaoli.jpg" + val saveFile = File(savePath) + val tempFile = File(saveFile.parentFile, "${saveFile.name}.part") return try { - val conn = URL(urlStr).openConnection() + tempFile.delete() + val conn = URL(urlStr).openConnection() as javax.net.ssl.HttpsURLConnection conn.connectTimeout = 10_000 conn.readTimeout = 10_000 conn.useCaches = false - if (conn is javax.net.ssl.HttpsURLConnection) { - conn.hostnameVerifier = javax.net.ssl.HostnameVerifier { hostname, session -> - if (hostname == serverIp) true else javax.net.ssl.HttpsURLConnection.getDefaultHostnameVerifier().verify(hostname, session) - } + conn.instanceFollowRedirects = false + val status = conn.responseCode + require(status in 200..299) { "Calendar download returned HTTP $status" } + require(conn.contentType?.substringBefore(';')?.startsWith("image/") == true) { + "Calendar download returned a non-image response" } - val totalBytes = conn.contentLength + val totalBytes = conn.contentLengthLong var downloadedBytes = 0 conn.getInputStream().use { input -> - FileOutputStream(File(savePath)).use { output -> + FileOutputStream(tempFile).use { output -> val buffer = ByteArray(8 * 1024) var bytes = input.read(buffer) while (bytes >= 0) { @@ -627,15 +616,48 @@ object RustSDK { } bytes = input.read(buffer) } + output.fd.sync() } } + require(downloadedBytes > 0) { "Calendar image is empty" } + require(totalBytes <= 0 || downloadedBytes.toLong() == totalBytes) { + "Calendar image is incomplete" + } + require(isValidCalendarImage(tempFile)) { "Calendar response cannot be decoded" } + try { + Files.move( + tempFile.toPath(), + saveFile.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + } catch (_: Exception) { + Files.move(tempFile.toPath(), saveFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + } true } catch (e: Exception) { Log.e(TAG_HOTUPDATE, "Failed to download calendar (Kotlin fallback)", e) + tempFile.delete() false } } + private fun isValidCalendarImage(file: File): Boolean { + if (!file.isFile || file.length() <= 0L) return false + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, options) + return options.outWidth > 0 && options.outHeight > 0 + } + + private fun requireTrustedUpdateUrl(url: URL) { + require(url.protocol.equals("https", ignoreCase = true)) { "Update URL must use HTTPS" } + require(url.port == -1 || url.port == 443) { "Update URL must use the default HTTPS port" } + val host = url.host.lowercase() + require(host == "openahu.org" || host.endsWith(".openahu.org")) { + "Untrusted update host" + } + } + fun saveImageToGallery(context: Context, imageFile: File) { val values = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, "AHU_Calendar_${System.currentTimeMillis()}.jpg") diff --git a/app/src/main/java/com/ahu/ahutong/ui/component/SecurePaymentPasswordDialog.kt b/app/src/main/java/com/ahu/ahutong/ui/component/SecurePaymentPasswordDialog.kt new file mode 100644 index 00000000..a810d488 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/component/SecurePaymentPasswordDialog.kt @@ -0,0 +1,431 @@ +package com.ahu.ahutong.ui.component + +import android.view.Window +import android.view.WindowManager +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import com.ahu.ahutong.data.dao.PreferencesManager +import java.util.WeakHashMap +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first + +@Composable +fun SecurePaymentPasswordDialog( + password: String, + onPasswordChange: (String) -> Unit, + title: String, + onDismissRequest: () -> Unit, + onConfirm: (String) -> Unit, + errorMessage: String? = null +) { + val context = LocalContext.current + val preferencesManager = remember(context) { + PreferencesManager(context.applicationContext) + } + val useBuiltInKeyboard by produceState( + initialValue = null, + key1 = preferencesManager + ) { + value = preferencesManager.useBuiltInSecurePasswordKeyboard.first() + } + + SecureWindowEffect() + + when (useBuiltInKeyboard) { + true -> BuiltInSecurePaymentPasswordDialog( + password = password, + onPasswordChange = onPasswordChange, + title = title, + onDismissRequest = onDismissRequest, + onConfirm = onConfirm, + errorMessage = errorMessage + ) + + false -> SystemPaymentPasswordDialog( + password = password, + onPasswordChange = onPasswordChange, + title = title, + onDismissRequest = onDismissRequest, + onConfirm = onConfirm, + errorMessage = errorMessage + ) + + null -> Unit + } +} + +@Composable +private fun BuiltInSecurePaymentPasswordDialog( + password: String, + onPasswordChange: (String) -> Unit, + title: String, + onDismissRequest: () -> Unit, + onConfirm: (String) -> Unit, + errorMessage: String? +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false + ) + ) { + Column(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 24.dp, vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 560.dp), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 6.dp + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall + ) + PasswordDots(passwordLength = password.length) + errorMessage?.let { message -> + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismissRequest) { + Text("取消") + } + TextButton( + onClick = { onConfirm(password) }, + enabled = password.length == PASSWORD_LENGTH + ) { + Text("确认") + } + } + } + } + } + + SecureWindowEffect() + NumericPasswordKeypad( + onDigit = { digit -> + if (password.length < PASSWORD_LENGTH) { + onPasswordChange(password + digit) + } + }, + onBackspace = { + if (password.isNotEmpty()) onPasswordChange(password.dropLast(1)) + }, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainer) + .navigationBarsPadding() + .padding(horizontal = 6.dp, vertical = 8.dp) + ) + } + } +} + +@Composable +private fun SystemPaymentPasswordDialog( + password: String, + onPasswordChange: (String) -> Unit, + title: String, + onDismissRequest: () -> Unit, + onConfirm: (String) -> Unit, + errorMessage: String? +) { + val focusRequester = remember { FocusRequester() } + + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(title) }, + text = { + SecureWindowEffect() + val keyboardController = LocalSoftwareKeyboardController.current + LaunchedEffect(Unit) { + delay(SYSTEM_KEYBOARD_FOCUS_DELAY_MS) + focusRequester.requestFocus() + keyboardController?.show() + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = password, + onValueChange = { value -> + if (value.length <= PASSWORD_LENGTH && value.all(Char::isDigit)) { + onPasswordChange(value) + } + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + label = { Text("6 位数字密码") }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + if (password.length == PASSWORD_LENGTH) onConfirm(password) + } + ), + isError = errorMessage != null, + singleLine = true + ) + errorMessage?.let { message -> + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(password) }, + enabled = password.length == PASSWORD_LENGTH + ) { + Text("确认") + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text("取消") + } + } + ) +} + +@Composable +private fun PasswordDots(passwordLength: Int) { + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { + contentDescription = "已输入 $passwordLength 位,共 $PASSWORD_LENGTH 位" + }, + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + repeat(PASSWORD_LENGTH) { index -> + Box( + modifier = Modifier + .size(18.dp) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = CircleShape + ) + .then( + if (index < passwordLength) { + Modifier.background( + color = MaterialTheme.colorScheme.onSurface, + shape = CircleShape + ) + } else { + Modifier + } + ) + ) + } + } +} + +@Composable +private fun NumericPasswordKeypad( + onDigit: (Char) -> Unit, + onBackspace: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + listOf("123", "456", "789").forEach { rowDigits -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + rowDigits.forEach { digit -> + PasswordKey( + label = digit.toString(), + contentDescription = "数字 $digit", + onClick = { onDigit(digit) }, + modifier = Modifier.weight(1f) + ) + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .weight(1f) + .height(KEY_HEIGHT) + ) + PasswordKey( + label = "0", + contentDescription = "数字 0", + onClick = { onDigit('0') }, + modifier = Modifier.weight(1f) + ) + PasswordKey( + label = "⌫", + contentDescription = "删除上一位", + onClick = onBackspace, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +private fun PasswordKey( + label: String, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier + .semantics { this.contentDescription = contentDescription } + .height(KEY_HEIGHT) + .clickable(onClick = onClick), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + tonalElevation = 1.dp + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium + ) + } + } +} + +@Composable +private fun SecureWindowEffect() { + val activityWindow = LocalActivity.current?.window + val dialogWindow = (LocalView.current.parent as? DialogWindowProvider)?.window + val windows = listOfNotNull(activityWindow, dialogWindow).distinct() + DisposableEffect(windows) { + windows.forEach(SecureWindowRegistry::acquire) + onDispose { + windows.forEach(SecureWindowRegistry::release) + } + } +} + +private object SecureWindowRegistry { + private data class WindowState( + var holderCount: Int, + val wasSecureBeforeAcquire: Boolean + ) + + private val states = WeakHashMap() + + @Synchronized + fun acquire(window: Window) { + val existing = states[window] + if (existing != null) { + existing.holderCount += 1 + return + } + + val wasSecure = window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE != 0 + if (!wasSecure) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + states[window] = WindowState( + holderCount = 1, + wasSecureBeforeAcquire = wasSecure + ) + } + + @Synchronized + fun release(window: Window) { + val state = states[window] ?: return + state.holderCount -= 1 + if (state.holderCount <= 0) { + states.remove(window) + if (!state.wasSecureBeforeAcquire) { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + } +} + +private const val PASSWORD_LENGTH = 6 +private const val SYSTEM_KEYBOARD_FOCUS_DELAY_MS = 200L +private val KEY_HEIGHT = 56.dp diff --git a/app/src/main/java/com/ahu/ahutong/ui/components/AppComponents.kt b/app/src/main/java/com/ahu/ahutong/ui/components/AppComponents.kt new file mode 100644 index 00000000..0faa3fec --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/components/AppComponents.kt @@ -0,0 +1,1977 @@ +package com.ahu.ahutong.ui.components + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.background +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Button as MaterialButton +import androidx.compose.material3.ButtonDefaults as MaterialButtonDefaults +import androidx.compose.material3.Card as MaterialCard +import androidx.compose.material3.CardDefaults as MaterialCardDefaults +import androidx.compose.material3.CircularProgressIndicator as MaterialCircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FloatingActionButton as MaterialFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Switch as MaterialSwitch +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.ModalBottomSheet as MaterialModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.ahu.ahutong.data.model.AppUiTheme +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel +import com.kyant.backdrop.Backdrop +import top.yukonga.miuix.kmp.basic.Button as MiuixButton +import top.yukonga.miuix.kmp.basic.ButtonColors as MiuixButtonColors +import top.yukonga.miuix.kmp.basic.Card as MiuixCard +import top.yukonga.miuix.kmp.basic.CircularProgressIndicator as MiuixCircularProgressIndicator +import top.yukonga.miuix.kmp.basic.FloatingActionButton as MiuixFloatingActionButton +import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon +import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton +import top.yukonga.miuix.kmp.basic.InputField as MiuixSearchInputField +import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField +import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior +import top.yukonga.miuix.kmp.basic.ProgressIndicatorDefaults as MiuixProgressIndicatorDefaults +import top.yukonga.miuix.kmp.basic.Scaffold as MiuixScaffold +import top.yukonga.miuix.kmp.basic.Surface as MiuixSurface +import top.yukonga.miuix.kmp.basic.Switch as MiuixSwitch +import top.yukonga.miuix.kmp.basic.TopAppBar as MiuixTopAppBar +import top.yukonga.miuix.kmp.extra.SuperDropdown +import top.yukonga.miuix.kmp.extra.SuperBottomSheet as MiuixSuperBottomSheet +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.icons.useful.Back +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.utils.scrollEndHaptic +import top.yukonga.miuix.kmp.utils.PressFeedbackType +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +/** Shared geometry for app-level controls and surfaces. */ +object AppComponentTokens { + val TouchTarget = 48.dp + val SearchFieldHeight = 56.dp + val ChipHeight = 40.dp + val HeaderHorizontalPadding = 20.dp + val HeaderVerticalPadding = 14.dp + val ControlShape = SmoothRoundedCornerShape(24.dp) + val CardShape = SmoothRoundedCornerShape(24.dp) + val LargeCardShape = SmoothRoundedCornerShape(32.dp) + val DialogShape = SmoothRoundedCornerShape(28.dp) + val DialogMaxWidth = 560.dp +} + +enum class AppButtonVariant { + Primary, + Secondary, + Destructive +} + +data class AppSelectOption( + val value: T, + val label: String +) + +/** A theme-native content card without leaking Material ripple or geometry into other themes. */ +@Composable +fun AppCard( + modifier: Modifier = Modifier, + shape: Shape = AppComponentTokens.CardShape, + contentPadding: PaddingValues = PaddingValues(16.dp), + enabled: Boolean = true, + onClick: (() -> Unit)? = null, + backdrop: Backdrop? = null, + content: @Composable ColumnScope.() -> Unit +) { + val uiTheme = LocalAppUiTheme.current + val haptic = LocalHapticFeedback.current + val action = onClick?.let { click -> + { + if (uiTheme == AppUiTheme.MIUIX) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + click() + } + } + + when (uiTheme) { + AppUiTheme.MIUIX -> { + if (action == null || !enabled) { + MiuixCard( + modifier = modifier, + cornerRadius = 16.dp, + insideMargin = contentPadding, + content = content + ) + } else { + MiuixCard( + modifier = modifier, + cornerRadius = 16.dp, + insideMargin = contentPadding, + pressFeedbackType = PressFeedbackType.Sink, + onClick = action, + content = content + ) + } + } + + AppUiTheme.MATERIAL -> { + if (action == null) { + MaterialCard( + modifier = modifier, + shape = shape, + colors = MaterialCardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) + ) { + Column(modifier = Modifier.padding(contentPadding), content = content) + } + } else { + MaterialCard( + onClick = action, + modifier = modifier, + enabled = enabled, + shape = shape, + colors = MaterialCardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) + ) { + Column(modifier = Modifier.padding(contentPadding), content = content) + } + } + } + + AppUiTheme.LIQUID_GLASS -> { + Column( + modifier = modifier + .appLiquidGlassSurface( + shape = shape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainer, + level = LiquidGlassSurfaceLevel.Panel, + backdrop = backdrop, + backdropSamplingEnabled = true + ) + .then( + if (action != null) { + Modifier.clickable( + enabled = enabled, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + role = Role.Button, + onClick = action + ) + } else { + Modifier + } + ) + .padding(contentPadding), + content = content + ) + } + } +} + +@Composable +fun AppHeaderIconButton( + imageVector: ImageVector, + miuixImageVector: ImageVector = imageVector, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + backdrop: Backdrop? = null, + tint: Color? = null +) { + when (LocalAppUiTheme.current) { + AppUiTheme.MIUIX -> { + val haptic = LocalHapticFeedback.current + MiuixIconButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onClick() + }, + modifier = modifier.size(AppComponentTokens.TouchTarget), + minWidth = AppComponentTokens.TouchTarget, + minHeight = AppComponentTokens.TouchTarget, + backgroundColor = MiuixTheme.colorScheme.surfaceContainer + ) { + MiuixIcon( + imageVector = miuixImageVector, + contentDescription = contentDescription, + tint = tint ?: MiuixTheme.colorScheme.onSurface + ) + } + return + } + AppUiTheme.MATERIAL -> { + IconButton( + onClick = onClick, + modifier = modifier.size(AppComponentTokens.TouchTarget) + ) { + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + tint = tint ?: MaterialTheme.colorScheme.onSurface + ) + } + return + } + AppUiTheme.LIQUID_GLASS -> Unit + } + Box( + modifier = modifier + .size(AppComponentTokens.TouchTarget) + .appLiquidGlassSurface( + shape = AppComponentTokens.ControlShape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainerHigh, + level = LiquidGlassSurfaceLevel.Control, + backdrop = backdrop + ) + .clickable(role = Role.Button, onClick = onClick), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + tint = tint ?: MaterialTheme.colorScheme.onSurface + ) + } +} + +@Composable +fun AppPageHeader( + title: String, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, + backdrop: Backdrop? = null, + horizontalPadding: Dp = AppComponentTokens.HeaderHorizontalPadding, + verticalPadding: Dp = AppComponentTokens.HeaderVerticalPadding, + actions: @Composable RowScope.() -> Unit = {} +) { + if (LocalAppUiTheme.current == AppUiTheme.MIUIX) { + val haptic = LocalHapticFeedback.current + MiuixTopAppBar( + title = title, + largeTitle = title, + modifier = modifier, + navigationIcon = { + onBack?.let { callback -> + MiuixIconButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + callback() + } + ) { + MiuixIcon( + imageVector = MiuixIcons.Useful.Back, + contentDescription = "返回", + tint = MiuixTheme.colorScheme.onSurface + ) + } + } + }, + actions = actions + ) + return + } + Row( + modifier = modifier + .fillMaxWidth() + .padding( + horizontal = horizontalPadding, + vertical = verticalPadding + ), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + onBack?.let { + AppHeaderIconButton( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "返回", + onClick = it, + backdrop = backdrop + ) + } + Text( + text = title, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurface, + style = if (onBack == null) { + MaterialTheme.typography.headlineLarge + } else { + MaterialTheme.typography.headlineMedium + }, + fontWeight = FontWeight.SemiBold + ) + actions() + } +} + +/** Page shell for screens that own their scrolling container. */ +@Composable +fun AppPageLayout( + title: String, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, + content: @Composable BoxScope.() -> Unit +) { + if (LocalAppUiTheme.current != AppUiTheme.MIUIX) { + Column( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + ) { + AppPageHeader( + title = title, + onBack = onBack, + actions = actions + ) + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + content = content + ) + } + return + } + + val scrollBehavior = MiuixScrollBehavior() + val haptic = LocalHapticFeedback.current + MiuixScaffold( + modifier = modifier.fillMaxSize(), + containerColor = Color.Transparent, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + MiuixTopAppBar( + title = title, + largeTitle = title, + scrollBehavior = scrollBehavior, + navigationIcon = { + onBack?.let { callback -> + MiuixIconButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + callback() + } + ) { + MiuixIcon( + imageVector = MiuixIcons.Useful.Back, + contentDescription = "返回", + tint = MiuixTheme.colorScheme.onSurface + ) + } + } + }, + actions = actions + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = paddingValues.calculateTopPadding() + 20.dp) + .navigationBarsPadding() + .nestedScroll(scrollBehavior.nestedScrollConnection) + .scrollEndHaptic(), + content = content + ) + } +} + +/** + * Shared vertically scrolling page shell. Miuix owns the collapsible title and navigation + * controls; Material and Liquid Glass retain their own in-content header treatment. + */ +@Composable +fun AppScrollablePageLayout( + title: String, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, + backdrop: Backdrop? = null, + scrollState: ScrollState = rememberScrollState(), + scrollEnabled: Boolean = true, + bottomPadding: Dp = 112.dp, + actions: @Composable RowScope.() -> Unit = {}, + content: @Composable ColumnScope.() -> Unit +) { + val uiTheme = LocalAppUiTheme.current + LaunchedEffect(uiTheme) { + scrollState.scrollTo(0) + } + if (uiTheme != AppUiTheme.MIUIX) { + Column( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + ) { + AppPageHeader( + title = title, + onBack = onBack, + backdrop = backdrop, + actions = actions + ) + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(scrollState, enabled = scrollEnabled) + .padding(bottom = bottomPadding), + verticalArrangement = Arrangement.spacedBy(24.dp), + content = content + ) + } + return + } + + val scrollBehavior = MiuixScrollBehavior() + val haptic = LocalHapticFeedback.current + MiuixScaffold( + modifier = modifier.fillMaxSize(), + containerColor = Color.Transparent, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + MiuixTopAppBar( + title = title, + largeTitle = title, + scrollBehavior = scrollBehavior, + navigationIcon = { + onBack?.let { callback -> + MiuixIconButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + callback() + } + ) { + MiuixIcon( + imageVector = MiuixIcons.Useful.Back, + contentDescription = "返回", + tint = MiuixTheme.colorScheme.onSurface + ) + } + } + }, + actions = actions + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection) + .scrollEndHaptic() + .verticalScroll(scrollState, enabled = scrollEnabled) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + top = paddingValues.calculateTopPadding() + 20.dp, + bottom = bottomPadding + ) + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(24.dp), + content = content + ) + } + } +} + +/** Lazy counterpart of [AppScrollablePageLayout], avoiding a nested scroll container. */ +@Composable +fun AppLazyPageLayout( + title: String, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, + state: LazyListState = rememberLazyListState(), + bottomPadding: Dp = 112.dp, + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(16.dp), + actions: @Composable RowScope.() -> Unit = {}, + content: LazyListScope.() -> Unit +) { + val uiTheme = LocalAppUiTheme.current + LaunchedEffect(uiTheme) { + state.scrollToItem(0) + } + if (uiTheme != AppUiTheme.MIUIX) { + Column( + modifier = modifier + .fillMaxSize() + .systemBarsPadding() + ) { + AppPageHeader(title = title, onBack = onBack, actions = actions) + LazyColumn( + state = state, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentPadding = PaddingValues(bottom = bottomPadding), + verticalArrangement = verticalArrangement, + content = content + ) + } + return + } + + val scrollBehavior = MiuixScrollBehavior() + val haptic = LocalHapticFeedback.current + MiuixScaffold( + modifier = modifier.fillMaxSize(), + containerColor = Color.Transparent, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + MiuixTopAppBar( + title = title, + largeTitle = title, + scrollBehavior = scrollBehavior, + navigationIcon = { + onBack?.let { callback -> + MiuixIconButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + callback() + } + ) { + MiuixIcon( + imageVector = MiuixIcons.Useful.Back, + contentDescription = "返回", + tint = MiuixTheme.colorScheme.onSurface + ) + } + } + }, + actions = actions + ) + } + ) { paddingValues -> + LazyColumn( + state = state, + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection) + .scrollEndHaptic(), + contentPadding = PaddingValues( + top = paddingValues.calculateTopPadding() + 20.dp, + bottom = bottomPadding + ), + verticalArrangement = verticalArrangement, + content = content + ) + } +} + +/** Theme-native indeterminate loading control. */ +@Composable +fun AppCircularProgressIndicator( + progress: (() -> Float)? = null, + modifier: Modifier = Modifier, + size: Dp = 30.dp, + strokeWidth: Dp = 4.dp, + color: Color? = null +) { + when (LocalAppUiTheme.current) { + AppUiTheme.MIUIX -> MiuixCircularProgressIndicator( + progress = progress?.invoke(), + modifier = modifier, + size = size, + strokeWidth = strokeWidth, + colors = MiuixProgressIndicatorDefaults.progressIndicatorColors( + foregroundColor = color ?: MiuixTheme.colorScheme.primary, + backgroundColor = (color ?: MiuixTheme.colorScheme.primary).copy(alpha = 0.16f) + ) + ) + AppUiTheme.MATERIAL -> if (progress == null) { + MaterialCircularProgressIndicator( + modifier = modifier.size(size), + color = color ?: MaterialTheme.colorScheme.primary, + strokeWidth = strokeWidth + ) + } else { + MaterialCircularProgressIndicator( + progress = progress, + modifier = modifier.size(size), + color = color ?: MaterialTheme.colorScheme.primary, + strokeWidth = strokeWidth + ) + } + AppUiTheme.LIQUID_GLASS -> LiquidGlassProgressIndicator( + progress = progress?.invoke(), + modifier = modifier, + size = size, + strokeWidth = strokeWidth, + color = color ?: MaterialTheme.colorScheme.primary + ) + } +} + +@Composable +private fun LiquidGlassProgressIndicator( + progress: Float?, + modifier: Modifier, + size: Dp, + strokeWidth: Dp, + color: Color +) { + if (progress != null) { + Canvas(modifier = modifier.size(size)) { + val width = strokeWidth.toPx() + drawCircle( + color = color.copy(alpha = 0.16f), + radius = (this.size.minDimension - width) / 2f, + style = androidx.compose.ui.graphics.drawscope.Stroke(width = width) + ) + drawArc( + color = color, + startAngle = -90f, + sweepAngle = 360f * progress.coerceIn(0f, 1f), + useCenter = false, + style = androidx.compose.ui.graphics.drawscope.Stroke( + width = width, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + ) + } + return + } + val transition = rememberInfiniteTransition(label = "liquid-loading") + val rotation by transition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 900, easing = LinearEasing) + ), + label = "liquid-loading-rotation" + ) + Canvas(modifier = modifier.size(size)) { + val radius = this.size.minDimension / 2f + val segmentStart = radius * 0.48f + val segmentEnd = radius * 0.82f + repeat(12) { index -> + val alpha = 0.16f + 0.84f * ((index + 1) / 12f) + rotate(degrees = rotation + index * 30f) { + drawLine( + color = color.copy(alpha = alpha), + start = androidx.compose.ui.geometry.Offset(center.x, center.y - segmentEnd), + end = androidx.compose.ui.geometry.Offset(center.x, center.y - segmentStart), + strokeWidth = strokeWidth.toPx(), + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + } + } + } +} + +@Composable +fun AppFloatingActionButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + when (LocalAppUiTheme.current) { + AppUiTheme.MIUIX -> MiuixFloatingActionButton( + onClick = onClick, + modifier = modifier, + content = content + ) + AppUiTheme.MATERIAL -> MaterialFloatingActionButton( + onClick = onClick, + modifier = modifier, + content = content + ) + AppUiTheme.LIQUID_GLASS -> Box( + modifier = modifier + .size(60.dp) + .appLiquidGlassSurface( + shape = AppComponentTokens.ControlShape, + fallbackColor = MaterialTheme.colorScheme.primaryContainer, + level = LiquidGlassSurfaceLevel.Floating, + backdrop = LocalLiquidGlassAmbientBackdrop.current, + backdropSamplingEnabled = false + ) + .clickable(role = Role.Button, onClick = onClick), + contentAlignment = Alignment.Center + ) { content() } + } +} + +@Composable +fun AppSearchHeader( + title: String, + searchActive: Boolean, + query: String, + onQueryChange: (String) -> Unit, + onSearchOpen: () -> Unit, + onSearchClose: () -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + onSearch: (() -> Unit)? = null, + edgePadding: Dp = AppComponentTokens.HeaderHorizontalPadding, + actions: @Composable RowScope.() -> Unit = {} +) { + if (!searchActive) { + AppPageHeader( + title = title, + modifier = modifier, + horizontalPadding = edgePadding, + actions = { + AppHeaderIconButton( + imageVector = Icons.Rounded.Search, + contentDescription = "搜索", + onClick = onSearchOpen + ) + actions() + } + ) + return + } + + BackHandler(onBack = onSearchClose) + Row( + modifier = modifier + .fillMaxWidth() + .padding( + horizontal = edgePadding, + vertical = AppComponentTokens.HeaderVerticalPadding + ), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AppHeaderIconButton( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "关闭搜索", + onClick = onSearchClose + ) + TextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier + .weight(1f) + .height(AppComponentTokens.SearchFieldHeight), + singleLine = true, + placeholder = { Text(placeholder) }, + shape = AppComponentTokens.ControlShape, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearch?.invoke() }), + trailingIcon = { + when { + query.isNotEmpty() -> IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Rounded.Close, contentDescription = "清空") + } + onSearch != null -> IconButton(onClick = onSearch) { + Icon(Icons.Rounded.Search, contentDescription = "搜索") + } + } + }, + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + cursorColor = MaterialTheme.colorScheme.primary + ) + ) + } +} + +@Composable +fun AppSearchField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + onSearch: (String) -> Unit = {} +) { + if (LocalAppUiTheme.current == AppUiTheme.MIUIX) { + MiuixSearchInputField( + query = value, + onQueryChange = onValueChange, + label = placeholder, + onSearch = onSearch, + expanded = true, + onExpandedChange = {}, + modifier = modifier + ) + return + } + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + singleLine = true, + placeholder = { Text(placeholder) }, + shape = AppComponentTokens.ControlShape, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearch(value) }), + colors = if (LocalAppUiTheme.current == AppUiTheme.LIQUID_GLASS) { + OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.28f), + unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.28f), + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.72f), + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.48f) + ) + } else { + OutlinedTextFieldDefaults.colors() + } + ) +} + +@Composable +fun AppTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + singleLine: Boolean = true, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None +) { + if (LocalAppUiTheme.current == AppUiTheme.MIUIX) { + MiuixTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + label = label, + useLabelAsPlaceholder = true, + enabled = enabled, + singleLine = singleLine, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + visualTransformation = visualTransformation + ) + return + } + val liquid = LocalAppUiTheme.current == AppUiTheme.LIQUID_GLASS + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + singleLine = singleLine, + label = { Text(label) }, + shape = AppComponentTokens.ControlShape, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + visualTransformation = visualTransformation, + colors = if (liquid) { + OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.28f), + unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.28f), + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.72f), + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.48f) + ) + } else { + OutlinedTextFieldDefaults.colors() + } + ) +} + +@Composable +fun AppButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + variant: AppButtonVariant = AppButtonVariant.Primary, + content: @Composable RowScope.() -> Unit +) { + val colors = MaterialTheme.colorScheme + val surfaceColor = when (variant) { + AppButtonVariant.Primary -> colors.primaryContainer + AppButtonVariant.Secondary -> colors.surfaceContainerHigh + AppButtonVariant.Destructive -> colors.errorContainer + } + val contentColor = when (variant) { + AppButtonVariant.Primary -> colors.onPrimaryContainer + AppButtonVariant.Secondary -> colors.onSurface + AppButtonVariant.Destructive -> colors.onErrorContainer + } + val tint = when (variant) { + AppButtonVariant.Primary -> colors.primary + AppButtonVariant.Secondary -> colors.secondary + AppButtonVariant.Destructive -> colors.error + } + + val liquidContent: @Composable RowScope.() -> Unit = { + CompositionLocalProvider( + LocalContentColor provides contentColor.copy(alpha = if (enabled) 1f else 0.72f) + ) { content() } + } + when (LocalAppUiTheme.current) { + AppUiTheme.MIUIX -> { + val haptic = LocalHapticFeedback.current + val miuixColor = when (variant) { + AppButtonVariant.Primary -> MiuixTheme.colorScheme.primary + AppButtonVariant.Secondary -> MiuixTheme.colorScheme.secondaryVariant + AppButtonVariant.Destructive -> colors.error + } + val miuixDisabledColor = when (variant) { + AppButtonVariant.Primary -> MiuixTheme.colorScheme.disabledPrimaryButton + else -> MiuixTheme.colorScheme.disabledSecondaryVariant + } + val miuixContentColor = when (variant) { + AppButtonVariant.Primary -> MiuixTheme.colorScheme.onPrimary + AppButtonVariant.Secondary -> MiuixTheme.colorScheme.onSecondaryVariant + AppButtonVariant.Destructive -> colors.onError + } + MiuixButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onClick() + }, + modifier = modifier, + enabled = enabled, + minHeight = AppComponentTokens.TouchTarget, + colors = MiuixButtonColors(miuixColor, miuixDisabledColor), + ) { + CompositionLocalProvider( + LocalContentColor provides miuixContentColor.copy( + alpha = if (enabled) 1f else 0.60f + ) + ) { content() } + } + } + AppUiTheme.MATERIAL -> { + val materialModifier = modifier.heightIn(min = AppComponentTokens.TouchTarget) + when (variant) { + AppButtonVariant.Secondary -> FilledTonalButton( + onClick = onClick, + modifier = materialModifier, + enabled = enabled, + content = content + ) + AppButtonVariant.Primary, AppButtonVariant.Destructive -> MaterialButton( + onClick = onClick, + modifier = materialModifier, + enabled = enabled, + colors = if (variant == AppButtonVariant.Destructive) { + MaterialButtonDefaults.buttonColors( + containerColor = colors.error, + contentColor = colors.onError + ) + } else { + MaterialButtonDefaults.buttonColors() + }, + content = content + ) + } + } + AppUiTheme.LIQUID_GLASS -> LiquidButton( + onClick = onClick, + backdrop = LocalLiquidGlassAmbientBackdrop.current, + modifier = modifier.heightIn(min = AppComponentTokens.TouchTarget), + enabled = enabled, + tint = tint, + surfaceColor = surfaceColor, + content = liquidContent + ) + } +} + +@Composable +fun AppToggle( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + contentDescription: String? = null +) { + when (LocalAppUiTheme.current) { + AppUiTheme.MIUIX -> { + MiuixSwitch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier, + enabled = enabled + ) + } + AppUiTheme.MATERIAL -> MaterialSwitch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier, + enabled = enabled + ) + AppUiTheme.LIQUID_GLASS -> LiquidToggle( + selected = { checked }, + onSelect = onCheckedChange, + backdrop = LocalLiquidGlassAmbientBackdrop.current, + modifier = modifier, + userInputEnabled = enabled, + contentDescription = contentDescription + ) + } +} + +/** Dispatches to three independent controls so one design system cannot leak into another. */ +@Composable +fun AppSelectField( + label: String, + selected: T?, + options: List>, + onSelected: (T) -> Unit, + modifier: Modifier = Modifier, + placeholder: String = "请选择", + enabled: Boolean = true, + valueTextAlign: TextAlign = TextAlign.Start, + miuixInsideMargin: PaddingValues = PaddingValues(16.dp), + miuixStandalone: Boolean = false, + liquidLabelWeight: Float = 1f, + liquidValueWeight: Float = 1f +) { + val selectedIndex = remember(options, selected) { + options.indexOfFirst { it.value == selected } + } + val selectedLabel = options.getOrNull(selectedIndex)?.label ?: placeholder + when (LocalAppUiTheme.current) { + AppUiTheme.MIUIX -> MiuixSelectField( + label = label, + selectedIndex = selectedIndex, + options = options, + onSelected = onSelected, + modifier = modifier, + enabled = enabled, + insideMargin = miuixInsideMargin, + standalone = miuixStandalone + ) + AppUiTheme.MATERIAL -> MaterialSelectField( + label = label, + selected = selected, + selectedLabel = selectedLabel, + options = options, + onSelected = onSelected, + modifier = modifier, + enabled = enabled, + valueTextAlign = valueTextAlign + ) + AppUiTheme.LIQUID_GLASS -> LiquidGlassSelectField( + label = label, + selected = selected, + selectedLabel = selectedLabel, + options = options, + onSelected = onSelected, + modifier = modifier, + enabled = enabled, + valueTextAlign = valueTextAlign, + labelWeight = liquidLabelWeight, + valueWeight = liquidValueWeight + ) + } +} + +@Composable +private fun MiuixSelectField( + label: String, + selectedIndex: Int, + options: List>, + onSelected: (T) -> Unit, + modifier: Modifier, + enabled: Boolean, + insideMargin: PaddingValues, + standalone: Boolean +) { + val optionLabels = remember(options) { options.map(AppSelectOption::label) } + val haptic = LocalHapticFeedback.current + val dropdown: @Composable (Modifier) -> Unit = { dropdownModifier -> + SuperDropdown( + items = optionLabels, + selectedIndex = selectedIndex.coerceAtLeast(0), + title = label, + modifier = dropdownModifier.fillMaxWidth(), + insideMargin = insideMargin, + enabled = enabled, + showValue = selectedIndex >= 0, + onSelectedIndexChange = { index -> + options.getOrNull(index)?.let { option -> + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onSelected(option.value) + } + } + ) + } + if (standalone) { + MiuixSurface( + modifier = modifier.fillMaxWidth(), + shape = SmoothRoundedCornerShape(16.dp), + color = MiuixTheme.colorScheme.surfaceContainer + ) { + dropdown(Modifier) + } + } else { + dropdown(modifier) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MaterialSelectField( + label: String, + selected: T?, + selectedLabel: String, + options: List>, + onSelected: (T) -> Unit, + modifier: Modifier, + enabled: Boolean, + valueTextAlign: TextAlign +) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { if (enabled) expanded = !expanded }, + modifier = modifier + ) { + OutlinedTextField( + value = selectedLabel, + onValueChange = {}, + readOnly = true, + enabled = enabled, + singleLine = true, + label = { Text(label) }, + textStyle = MaterialTheme.typography.bodyLarge.copy(textAlign = valueTextAlign), + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier + .menuAnchor( + type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, + enabled = enabled + ) + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + matchAnchorWidth = true, + shape = MenuDefaults.shape, + containerColor = MenuDefaults.containerColor, + tonalElevation = MenuDefaults.TonalElevation, + shadowElevation = MenuDefaults.ShadowElevation + ) { + options.forEach { option -> + val isSelected = option.value == selected + DropdownMenuItem( + text = { + Text( + text = option.label, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyLarge, + textAlign = valueTextAlign + ) + }, + trailingIcon = { + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + modifier = Modifier.background( + if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent + ), + onClick = { + onSelected(option.value) + expanded = false + } + ) + } + } + } +} + +@Composable +private fun LiquidGlassSelectField( + label: String, + selected: T?, + selectedLabel: String, + options: List>, + onSelected: (T) -> Unit, + modifier: Modifier, + enabled: Boolean, + valueTextAlign: TextAlign, + labelWeight: Float, + valueWeight: Float +) { + var expanded by remember { mutableStateOf(false) } + var anchorWidthPx by remember { mutableStateOf(0) } + var fieldBoundsInWindow by remember { mutableStateOf(IntRect(0, 0, 0, 0)) } + val popupVisibility = remember { MutableTransitionState(false) } + val haptic = LocalHapticFeedback.current + val density = LocalDensity.current + val popupBackdrop = LocalLiquidGlassContentBackdrop.current + val popupShape = SmoothRoundedCornerShape(20.dp) + val popupGapPx = with(density) { 6.dp.roundToPx() } + val popupWidth = with(density) { (anchorWidthPx / 2).toDp() } + val arrowRotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + animationSpec = tween(durationMillis = 180), + label = "liquid dropdown arrow" + ) + val popupPositionProvider = remember(popupGapPx, fieldBoundsInWindow) { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset { + val fieldBounds = fieldBoundsInWindow.takeIf { it.width > 0 && it.height > 0 } + ?: anchorBounds + val preferredX = fieldBounds.right - popupContentSize.width + val maxX = (windowSize.width - popupContentSize.width).coerceAtLeast(0) + val x = preferredX.coerceIn(0, maxX) + val below = fieldBounds.bottom + popupGapPx + val above = fieldBounds.top - popupContentSize.height - popupGapPx + val y = if (below + popupContentSize.height <= windowSize.height) { + below + } else { + above.coerceAtLeast(0) + } + return IntOffset(x, y) + } + } + } + LaunchedEffect(expanded) { + popupVisibility.targetState = expanded + } + Box(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = modifier + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + anchorWidthPx = coordinates.size.width + val bounds = coordinates.boundsInWindow() + fieldBoundsInWindow = IntRect( + left = bounds.left.roundToInt(), + top = bounds.top.roundToInt(), + right = bounds.right.roundToInt(), + bottom = bounds.bottom.roundToInt() + ) + } + .heightIn(min = 58.dp) + .appLiquidGlassSurface( + shape = AppComponentTokens.ControlShape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainer, + level = LiquidGlassSurfaceLevel.Control, + backdropSamplingEnabled = false + ) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + enabled = enabled, + role = Role.Button + ) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + expanded = !expanded + } + .padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + modifier = Modifier.weight(labelWeight), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else 0.38f), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = selectedLabel, + modifier = Modifier.weight(valueWeight), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = if (enabled) 1f else 0.38f + ), + style = MaterialTheme.typography.bodyLarge, + textAlign = valueTextAlign, + maxLines = 2 + ) + val arrowColor = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = if (enabled) 1f else 0.38f + ) + Canvas( + modifier = Modifier + .size(18.dp) + .graphicsLayer { rotationZ = arrowRotation } + ) { + drawLine( + color = arrowColor, + start = androidx.compose.ui.geometry.Offset(size.width * 0.2f, size.height * 0.38f), + end = androidx.compose.ui.geometry.Offset(size.width * 0.5f, size.height * 0.68f), + strokeWidth = 2.dp.toPx(), + cap = StrokeCap.Round + ) + drawLine( + color = arrowColor, + start = androidx.compose.ui.geometry.Offset(size.width * 0.5f, size.height * 0.68f), + end = androidx.compose.ui.geometry.Offset(size.width * 0.8f, size.height * 0.38f), + strokeWidth = 2.dp.toPx(), + cap = StrokeCap.Round + ) + } + } + if ((popupVisibility.currentState || popupVisibility.targetState) && anchorWidthPx > 0) { + Popup( + onDismissRequest = { expanded = false }, + popupPositionProvider = popupPositionProvider, + properties = PopupProperties(focusable = true) + ) { + val selectedColor = MaterialTheme.colorScheme.primary + AnimatedVisibility( + visibleState = popupVisibility, + enter = fadeIn(tween(durationMillis = 120)) + scaleIn( + initialScale = 0.96f, + transformOrigin = TransformOrigin(1f, 0f), + animationSpec = tween(durationMillis = 160) + ), + exit = fadeOut(tween(durationMillis = 90)) + scaleOut( + targetScale = 0.98f, + transformOrigin = TransformOrigin(1f, 0f), + animationSpec = tween(durationMillis = 110) + ) + ) { + Column( + modifier = Modifier + .width(popupWidth) + .heightIn(max = 360.dp) + .shadow( + elevation = 10.dp, + shape = popupShape, + clip = false, + ambientColor = Color.Black.copy(alpha = 0.08f), + spotColor = Color.Black.copy(alpha = 0.12f) + ) + .appLiquidGlassSurface( + shape = popupShape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + level = LiquidGlassSurfaceLevel.Floating, + backdrop = popupBackdrop, + backdropSamplingEnabled = true, + blurRadiusMultiplier = 1.6f, + tintAlphaMultiplier = 1.5f + ) + .verticalScroll(rememberScrollState()) + .padding(vertical = 6.dp) + ) { + options.forEach { option -> + val isSelected = option.value == selected + val interactionSource = remember(option.value) { MutableInteractionSource() } + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clickable( + interactionSource = interactionSource, + indication = null, + role = Role.Button + ) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onSelected(option.value) + expanded = false + } + .padding(horizontal = 14.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(22.dp), + contentAlignment = Alignment.Center + ) { + if (isSelected) { + Canvas(modifier = Modifier.size(18.dp)) { + drawLine( + color = selectedColor, + start = androidx.compose.ui.geometry.Offset( + size.width * 0.12f, + size.height * 0.55f + ), + end = androidx.compose.ui.geometry.Offset( + size.width * 0.4f, + size.height * 0.8f + ), + strokeWidth = 2.2.dp.toPx(), + cap = StrokeCap.Round + ) + drawLine( + color = selectedColor, + start = androidx.compose.ui.geometry.Offset( + size.width * 0.4f, + size.height * 0.8f + ), + end = androidx.compose.ui.geometry.Offset( + size.width * 0.9f, + size.height * 0.2f + ), + strokeWidth = 2.2.dp.toPx(), + cap = StrokeCap.Round + ) + } + } + } + Text( + text = option.label, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal + ) + } + } + } + } + } + } + } +} + +@Composable +internal fun LiquidGlassDropdownIndicator( + expanded: Boolean, + color: Color, + modifier: Modifier = Modifier +) { + val rotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + animationSpec = tween(durationMillis = 180), + label = "liquid settings dropdown arrow" + ) + Canvas( + modifier = modifier + .size(18.dp) + .graphicsLayer { rotationZ = rotation } + ) { + drawLine( + color = color, + start = androidx.compose.ui.geometry.Offset(size.width * 0.2f, size.height * 0.38f), + end = androidx.compose.ui.geometry.Offset(size.width * 0.5f, size.height * 0.68f), + strokeWidth = 2.dp.toPx(), + cap = StrokeCap.Round + ) + drawLine( + color = color, + start = androidx.compose.ui.geometry.Offset(size.width * 0.5f, size.height * 0.68f), + end = androidx.compose.ui.geometry.Offset(size.width * 0.8f, size.height * 0.38f), + strokeWidth = 2.dp.toPx(), + cap = StrokeCap.Round + ) + } +} + +@Composable +internal fun LiquidGlassDropdownPopup( + expanded: Boolean, + anchorBoundsInWindow: IntRect, + popupWidth: Dp, + selected: T?, + options: List>, + onSelected: (T) -> Unit, + onDismiss: () -> Unit +) { + val visibility = remember { MutableTransitionState(false) } + val density = LocalDensity.current + val popupGapPx = with(density) { 6.dp.roundToPx() } + val popupBackdrop = LocalLiquidGlassContentBackdrop.current + val popupShape = SmoothRoundedCornerShape(20.dp) + val haptic = LocalHapticFeedback.current + val positionProvider = remember(popupGapPx, anchorBoundsInWindow) { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset { + val fieldBounds = anchorBoundsInWindow.takeIf { it.width > 0 && it.height > 0 } + ?: anchorBounds + val preferredX = fieldBounds.right - popupContentSize.width + val maxX = (windowSize.width - popupContentSize.width).coerceAtLeast(0) + val x = preferredX.coerceIn(0, maxX) + val below = fieldBounds.bottom + popupGapPx + val above = fieldBounds.top - popupContentSize.height - popupGapPx + val y = if (below + popupContentSize.height <= windowSize.height) { + below + } else { + above.coerceAtLeast(0) + } + return IntOffset(x, y) + } + } + } + LaunchedEffect(expanded) { + visibility.targetState = expanded + } + if ( + (visibility.currentState || visibility.targetState) && + anchorBoundsInWindow.width > 0 && + popupWidth > 0.dp + ) { + Popup( + onDismissRequest = onDismiss, + popupPositionProvider = positionProvider, + properties = PopupProperties(focusable = true) + ) { + val selectedColor = MaterialTheme.colorScheme.primary + AnimatedVisibility( + visibleState = visibility, + enter = fadeIn(tween(durationMillis = 120)) + scaleIn( + initialScale = 0.96f, + transformOrigin = TransformOrigin(1f, 0f), + animationSpec = tween(durationMillis = 160) + ), + exit = fadeOut(tween(durationMillis = 90)) + scaleOut( + targetScale = 0.98f, + transformOrigin = TransformOrigin(1f, 0f), + animationSpec = tween(durationMillis = 110) + ) + ) { + Column( + modifier = Modifier + .width(popupWidth) + .heightIn(max = 360.dp) + .shadow( + elevation = 10.dp, + shape = popupShape, + clip = false, + ambientColor = Color.Black.copy(alpha = 0.08f), + spotColor = Color.Black.copy(alpha = 0.12f) + ) + .appLiquidGlassSurface( + shape = popupShape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + level = LiquidGlassSurfaceLevel.Floating, + backdrop = popupBackdrop, + backdropSamplingEnabled = true, + blurRadiusMultiplier = 1.6f, + tintAlphaMultiplier = 1.5f + ) + .verticalScroll(rememberScrollState()) + .padding(vertical = 6.dp) + ) { + options.forEach { option -> + val isSelected = option.value == selected + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clickable( + interactionSource = remember(option.value) { + MutableInteractionSource() + }, + indication = null, + role = Role.Button + ) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onSelected(option.value) + onDismiss() + } + .padding(horizontal = 14.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(22.dp), + contentAlignment = Alignment.Center + ) { + if (isSelected) { + Canvas(modifier = Modifier.size(18.dp)) { + drawLine( + color = selectedColor, + start = androidx.compose.ui.geometry.Offset( + size.width * 0.12f, + size.height * 0.55f + ), + end = androidx.compose.ui.geometry.Offset( + size.width * 0.4f, + size.height * 0.8f + ), + strokeWidth = 2.2.dp.toPx(), + cap = StrokeCap.Round + ) + drawLine( + color = selectedColor, + start = androidx.compose.ui.geometry.Offset( + size.width * 0.4f, + size.height * 0.8f + ), + end = androidx.compose.ui.geometry.Offset( + size.width * 0.9f, + size.height * 0.2f + ), + strokeWidth = 2.2.dp.toPx(), + cap = StrokeCap.Round + ) + } + } + } + Text( + text = option.label, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (isSelected) { + FontWeight.SemiBold + } else { + FontWeight.Normal + } + ) + } + } + } + } + } + } +} + +@Composable +fun AppFilterChip( + selected: Boolean, + onClick: () -> Unit, + label: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + val colors = MaterialTheme.colorScheme + if (LocalAppUiTheme.current == AppUiTheme.MIUIX) { + val containerColor = if (selected) { + MiuixTheme.colorScheme.primary + } else { + MiuixTheme.colorScheme.secondaryVariant + } + val labelColor = if (selected) { + MiuixTheme.colorScheme.onPrimary + } else { + MiuixTheme.colorScheme.onSecondaryVariant + } + MiuixSurface( + onClick = onClick, + enabled = enabled, + modifier = modifier.heightIn(min = AppComponentTokens.ChipHeight), + color = containerColor, + shape = SmoothRoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CompositionLocalProvider(LocalContentColor provides labelColor, content = label) + } + } + return + } + FilterChip( + selected = selected, + onClick = onClick, + label = label, + modifier = modifier.heightIn(min = AppComponentTokens.ChipHeight), + enabled = enabled, + shape = AppComponentTokens.ControlShape, + colors = FilterChipDefaults.filterChipColors( + containerColor = colors.surfaceContainerHigh, + labelColor = colors.onSurfaceVariant, + selectedContainerColor = colors.secondaryContainer, + selectedLabelColor = colors.onSecondaryContainer, + disabledContainerColor = colors.surfaceContainer, + disabledLabelColor = colors.onSurface.copy(alpha = 0.38f) + ) + ) +} + +@Composable +fun AppDialogSurface( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false), + content: @Composable ColumnScope.() -> Unit +) { + Dialog(onDismissRequest = onDismissRequest, properties = properties) { + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onSurface + ) { + androidx.compose.foundation.layout.Column( + modifier = modifier + .fillMaxWidth(0.9f) + .widthIn(max = AppComponentTokens.DialogMaxWidth) + .appLiquidGlassSurface( + shape = AppComponentTokens.DialogShape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainerHigh, + level = LiquidGlassSurfaceLevel.Floating, + backdropSamplingEnabled = false + ), + content = content + ) + } + } +} + +/** + * Theme-native modal sheet host. + * + * Miuix delegates to the library's SuperBottomSheet, Material keeps the M3 + * implementation, and LiquidGlass owns its scrim, surface, and motion instead + * of wrapping a transparent Material sheet. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppModalBottomSheet( + title: String, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + when (LocalAppUiTheme.current) { + AppUiTheme.MIUIX -> { + val show = remember { mutableStateOf(true) } + MiuixSuperBottomSheet( + show = show, + modifier = modifier, + title = title, + onDismissRequest = onDismissRequest, + content = { + Column( + modifier = Modifier.fillMaxWidth(), + content = content + ) + } + ) + } + + AppUiTheme.MATERIAL -> { + MaterialModalBottomSheet( + onDismissRequest = onDismissRequest, + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Text( + text = title, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Column( + modifier = Modifier.fillMaxWidth(), + content = content + ) + } + } + + AppUiTheme.LIQUID_GLASS -> { + val visibility = remember { + MutableTransitionState(false).apply { targetState = true } + } + val scope = rememberCoroutineScope() + var dismissing by remember { mutableStateOf(false) } + val requestDismiss = { + if (!dismissing) { + dismissing = true + visibility.targetState = false + scope.launch { + delay(180) + onDismissRequest() + } + } + } + + Dialog( + onDismissRequest = requestDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false + ) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.36f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = requestDismiss + ) + ) { + AnimatedVisibility( + visibleState = visibility, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + enter = fadeIn(tween(150)) + + slideInVertically(tween(220)) { height -> height / 5 }, + exit = fadeOut(tween(120)) + + slideOutVertically(tween(180)) { height -> height / 5 } + ) { + val sheetShape = SmoothRoundedCornerShape(32.dp) + Column( + modifier = modifier + .fillMaxWidth() + .heightIn(max = 720.dp) + .navigationBarsPadding() + .shadow(18.dp, sheetShape, clip = false) + .clip(sheetShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .border( + 0.75.dp, + MaterialTheme.colorScheme.outlineVariant, + sheetShape + ) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {} + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, top = 20.dp, end = 18.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Box( + modifier = Modifier + .size(40.dp) + .clip(SmoothRoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .clickable( + role = Role.Button, + onClick = requestDismiss + ), + contentAlignment = Alignment.Center + ) { + val closeIconColor = MaterialTheme.colorScheme.onSurfaceVariant + Canvas(modifier = Modifier.size(16.dp)) { + val stroke = 2.dp.toPx() + drawLine( + color = closeIconColor, + start = androidx.compose.ui.geometry.Offset(0f, 0f), + end = androidx.compose.ui.geometry.Offset(size.width, size.height), + strokeWidth = stroke, + cap = StrokeCap.Round + ) + drawLine( + color = closeIconColor, + start = androidx.compose.ui.geometry.Offset(size.width, 0f), + end = androidx.compose.ui.geometry.Offset(0f, size.height), + strokeWidth = stroke, + cap = StrokeCap.Round + ) + } + } + } + content() + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/ahu/ahutong/ui/components/LiquidBottomTab.kt b/app/src/main/java/com/ahu/ahutong/ui/components/LiquidBottomTab.kt index e6d1a9a0..277ca26f 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/components/LiquidBottomTab.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/components/LiquidBottomTab.kt @@ -1,11 +1,11 @@ package com.ahu.ahutong.ui.components -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.selection.selectable import androidx.compose.runtime.Composable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment @@ -22,6 +22,7 @@ internal val LocalLiquidBottomTabScale = @Composable fun RowScope.LiquidBottomTab( onClick: () -> Unit, + selected: Boolean, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit ) { @@ -29,7 +30,8 @@ fun RowScope.LiquidBottomTab( Column( modifier .clip(ContinuousCapsule) - .clickable( + .selectable( + selected = selected, interactionSource = null, indication = null, role = Role.Tab, 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 81e33211..57c27dae 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 @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -37,6 +38,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.fastRoundToInt import androidx.compose.ui.util.lerp +import com.ahu.ahutong.ui.theme.LocalLiquidGlassTokens import com.ahu.ahutong.ui.utils.DampedDragAnimation import com.ahu.ahutong.ui.utils.InteractiveHighlight import com.kyant.backdrop.Backdrop @@ -52,7 +54,6 @@ 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.n1 import com.kyant.monet.withNight import kotlinx.coroutines.flow.collectLatest @@ -70,26 +71,27 @@ fun LiquidBottomTabs( modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit ) { - val isLiquid = LocalIsLiquidGlassEnabled.current - val backdrop = if (isLiquid) backdrop else emptyBackdrop() + val tokens = LocalLiquidGlassTokens.current + val isLiquid = tokens.enabled + val canBlur = tokens.quality.supportsBlur + val canRefract = tokens.quality.supportsRefraction + val capturesBackdrop = tokens.quality.supportsBackdrop + val backdrop = if (capturesBackdrop) backdrop else emptyBackdrop() val isLightTheme = MaterialTheme.colorScheme.surface.luminance() > 0.5f - val accentColor = - if (isLiquid) { - if (isLightTheme) Color(0xFF0088FF) - else Color(0xFF0091FF) - } else { - 50.a1 withNight 60.a1 - } + val accentColor = MaterialTheme.colorScheme.primary val containerColor = - if (isLiquid) { + if (!isLiquid) { + 100.n1 withNight 20.n1 + } else if (!canBlur) { + tokens.floating.legacyTint + } else { if (isLightTheme) Color(0xFFFAFAFA).copy(0.4f) else Color(0xFF121212).copy(0.4f) - } else { - 100.n1 withNight 20.n1 } val tabsBackdrop = rememberLayerBackdrop() + val tabsSource: Backdrop = if (capturesBackdrop) tabsBackdrop else emptyBackdrop() BoxWithConstraints( modifier, @@ -178,6 +180,7 @@ fun LiquidBottomTabs( Row( Modifier + .selectableGroup() .graphicsLayer { translationX = panelOffset } @@ -185,10 +188,15 @@ fun LiquidBottomTabs( backdrop = backdrop, shape = { ContinuousCapsule }, effects = { - if (isLiquid) { + if (canBlur) { vibrancy() - blur(8f.dp.toPx()) - lens(24f.dp.toPx(), 24f.dp.toPx()) + blur(tokens.floating.blurRadius.toPx()) + } + if (canRefract) { + lens( + tokens.floating.refractionHeight.toPx(), + tokens.floating.refractionAmount.toPx() + ) } }, layerBlock = { @@ -219,7 +227,9 @@ fun LiquidBottomTabs( Modifier .clearAndSetSemantics {} .alpha(0f) - .layerBackdrop(tabsBackdrop) + .then( + if (capturesBackdrop) Modifier.layerBackdrop(tabsBackdrop) else Modifier + ) .graphicsLayer { translationX = panelOffset } @@ -227,13 +237,15 @@ fun LiquidBottomTabs( backdrop = backdrop, shape = { ContinuousCapsule }, effects = { - if (isLiquid) { - val progress = dampedDragAnimation.pressProgress + val progress = dampedDragAnimation.pressProgress + if (canBlur) { vibrancy() - blur(8f.dp.toPx()) + blur(tokens.floating.blurRadius.toPx()) + } + if (canRefract && progress > 0f) { lens( - 24f.dp.toPx() * progress, - 24f.dp.toPx() * progress + tokens.floating.refractionHeight.toPx() * progress, + tokens.floating.refractionAmount.toPx() * progress ) } }, @@ -268,16 +280,17 @@ fun LiquidBottomTabs( .then(interactiveHighlight.gestureModifier) .then(dampedDragAnimation.modifier) .drawBackdrop( - backdrop = rememberCombinedBackdrop(backdrop, tabsBackdrop), + backdrop = rememberCombinedBackdrop(backdrop, tabsSource), shape = { ContinuousCapsule }, effects = { - if (isLiquid) { + if (canRefract) { val progress = dampedDragAnimation.pressProgress - lens( - 10f.dp.toPx() * progress, - 14f.dp.toPx() * progress, - chromaticAberration = true - ) + if (progress > 0f) { + lens( + tokens.control.refractionHeight.toPx() * progress, + tokens.control.refractionAmount.toPx() * progress + ) + } } }, highlight = { diff --git a/app/src/main/java/com/ahu/ahutong/ui/components/LiquidButton.kt b/app/src/main/java/com/ahu/ahutong/ui/components/LiquidButton.kt index c3f0ca58..84f37ad1 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/components/LiquidButton.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/components/LiquidButton.kt @@ -1,24 +1,32 @@ package com.ahu.ahutong.ui.components import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember 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.alpha import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastCoerceAtMost import androidx.compose.ui.util.lerp +import com.ahu.ahutong.ui.theme.LiquidGlassQuality +import com.ahu.ahutong.ui.theme.LocalLiquidGlassTokens import com.ahu.ahutong.ui.utils.InteractiveHighlight import com.kyant.backdrop.Backdrop import com.kyant.backdrop.drawBackdrop @@ -37,11 +45,14 @@ fun LiquidButton( onClick: () -> Unit, backdrop: Backdrop, modifier: Modifier = Modifier, + enabled: Boolean = true, isInteractive: Boolean = true, tint: Color = Color.Unspecified, surfaceColor: Color = Color.Unspecified, content: @Composable RowScope.() -> Unit ) { + val tokens = LocalLiquidGlassTokens.current + val surfaceStyle = tokens.control val animationScope = rememberCoroutineScope() val interactiveHighlight = remember(animationScope) { @@ -50,62 +61,98 @@ fun LiquidButton( ) } - Row( - modifier - .drawBackdrop( - backdrop = backdrop, - shape = { ContinuousCapsule }, - effects = { - vibrancy() - blur(2f.dp.toPx()) - lens(12f.dp.toPx(), 24f.dp.toPx()) - }, - layerBlock = if (isInteractive) { - { - val width = size.width - val height = size.height + val fallbackSurface = when { + surfaceColor.isSpecified -> surfaceColor + tint.isSpecified -> tint.copy(alpha = 0.24f) + .compositeOver(MaterialTheme.colorScheme.secondaryContainer) + else -> MaterialTheme.colorScheme.secondaryContainer + } + val effectiveInteractive = isInteractive && enabled + val visualModifier = when (tokens.quality) { + LiquidGlassQuality.Disabled -> Modifier + .clip(ContinuousCapsule) + .background(fallbackSurface) - val progress = interactiveHighlight.pressProgress - val scale = lerp(1f, 1f + 4f.dp.toPx() / size.height, progress) + LiquidGlassQuality.Tinted -> Modifier + .clip(ContinuousCapsule) + .background( + when { + surfaceColor.isSpecified -> surfaceColor + tint.isSpecified -> tint.copy(alpha = 0.24f) + .compositeOver(surfaceStyle.legacyTint) + else -> surfaceStyle.legacyTint + } + ) + .border(0.5.dp, surfaceStyle.outline, ContinuousCapsule) - val maxOffset = size.minDimension - val initialDerivative = 0.05f - val offset = interactiveHighlight.offset - translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset) - translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset) + LiquidGlassQuality.Blurred, + LiquidGlassQuality.Refractive -> Modifier.drawBackdrop( + backdrop = backdrop, + shape = { ContinuousCapsule }, + effects = { + vibrancy() + blur(surfaceStyle.blurRadius.toPx()) + if (tokens.quality.supportsRefraction) { + lens( + surfaceStyle.refractionHeight.toPx(), + surfaceStyle.refractionAmount.toPx() + ) + } + }, + layerBlock = if (effectiveInteractive) { + { + val width = size.width + val height = size.height - val maxDragScale = 4f.dp.toPx() / size.height - val offsetAngle = atan2(offset.y, offset.x) - scaleX = - scale + - maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) * - (width / height).fastCoerceAtMost(1f) - scaleY = - scale + - maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) * - (height / width).fastCoerceAtMost(1f) - } - } else { - null - }, - onDrawSurface = { - if (tint.isSpecified) { - drawRect(tint, blendMode = BlendMode.Hue) - drawRect(tint.copy(alpha = 0.75f)) - } - if (surfaceColor.isSpecified) { - drawRect(surfaceColor) - } + val progress = interactiveHighlight.pressProgress + val scale = lerp(1f, 1f + 4f.dp.toPx() / size.height, progress) + + val maxOffset = size.minDimension + val initialDerivative = 0.05f + val offset = interactiveHighlight.offset + translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset) + translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset) + + val maxDragScale = 4f.dp.toPx() / size.height + val offsetAngle = atan2(offset.y, offset.x) + scaleX = + scale + + maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) * + (width / height).fastCoerceAtMost(1f) + scaleY = + scale + + maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) * + (height / width).fastCoerceAtMost(1f) } - ) + } else { + null + }, + onDrawSurface = { + drawRect(surfaceStyle.tint) + if (tint.isSpecified) { + drawRect(tint, blendMode = BlendMode.Hue) + drawRect(tint.copy(alpha = 0.75f)) + } + if (surfaceColor.isSpecified) { + drawRect(surfaceColor) + } + } + ) + } + + Row( + modifier + .alpha(if (enabled) 1f else 0.48f) + .then(visualModifier) .clickable( interactionSource = null, - indication = if (isInteractive) null else LocalIndication.current, + indication = if (effectiveInteractive) null else LocalIndication.current, role = Role.Button, + enabled = enabled, onClick = onClick ) .then( - if (isInteractive) { + if (effectiveInteractive) { Modifier .then(interactiveHighlight.modifier) .then(interactiveHighlight.gestureModifier) diff --git a/app/src/main/java/com/ahu/ahutong/ui/components/LiquidGlassSurface.kt b/app/src/main/java/com/ahu/ahutong/ui/components/LiquidGlassSurface.kt new file mode 100644 index 00000000..0246ec8a --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/components/LiquidGlassSurface.kt @@ -0,0 +1,212 @@ +package com.ahu.ahutong.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.unit.dp +import com.ahu.ahutong.ui.theme.LiquidGlassQuality +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel +import com.ahu.ahutong.ui.theme.LocalLiquidGlassTokens +import com.ahu.ahutong.data.model.AppUiTheme +import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.LayerBackdrop +import com.kyant.backdrop.backdrops.emptyBackdrop +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.lens +import com.kyant.backdrop.effects.vibrancy +import com.kyant.backdrop.highlight.Highlight +import com.kyant.backdrop.shadow.Shadow +import top.yukonga.miuix.kmp.theme.MiuixTheme + +val LocalLiquidGlassAmbientBackdrop = staticCompositionLocalOf { emptyBackdrop() } + +val LocalLiquidGlassContentBackdrop = staticCompositionLocalOf { emptyBackdrop() } + +private val LocalLiquidGlassContentLayer = staticCompositionLocalOf { null } + +/** + * Owns the two backdrop layers used by the app. + * + * The ambient layer contains only the stable background, so a glass card never samples itself. + * The content layer is captured separately for navigation and other overlays that should show the + * page underneath them. + */ +@Composable +fun LiquidGlassAppHost( + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit +) { + val tokens = LocalLiquidGlassTokens.current + val ambientLayer = rememberLayerBackdrop() + val contentLayer = rememberLayerBackdrop() + val capturesBackdrop = tokens.quality.supportsBackdrop + val ambientBackdrop: Backdrop = if (capturesBackdrop) ambientLayer else emptyBackdrop() + val contentBackdrop: Backdrop = if (capturesBackdrop) contentLayer else emptyBackdrop() + val appTheme = LocalAppUiTheme.current + val background = when (appTheme) { + AppUiTheme.MIUIX -> MiuixTheme.colorScheme.surface + AppUiTheme.MATERIAL -> MaterialTheme.colorScheme.background + AppUiTheme.LIQUID_GLASS -> tokens.screenBackground + } + + Box(modifier = modifier.background(background)) { + if (tokens.enabled) { + val primary = tokens.ambientPrimary.compositeOver(background) + val secondary = tokens.ambientSecondary.compositeOver(background) + Box( + modifier = Modifier + .matchParentSize() + .then( + if (capturesBackdrop) Modifier.layerBackdrop(ambientLayer) else Modifier + ) + .background( + Brush.verticalGradient( + listOf(background, primary, secondary, background) + ) + ) + ) + } + + CompositionLocalProvider( + LocalLiquidGlassAmbientBackdrop provides ambientBackdrop, + LocalLiquidGlassContentBackdrop provides contentBackdrop, + LocalLiquidGlassContentLayer provides contentLayer.takeIf { capturesBackdrop } + ) { + content() + } + } +} + +/** Captures page content only while liquid glass is enabled. */ +@Composable +fun Modifier.captureLiquidGlassContent(): Modifier { + val layer = LocalLiquidGlassContentLayer.current + return if (layer != null) layerBackdrop(layer) else this +} + +/** + * Applies the shared liquid-glass material and preserves the supplied opaque fallback when the + * preference is disabled. + */ +@Composable +fun Modifier.appLiquidGlassSurface( + shape: Shape, + fallbackColor: Color, + level: LiquidGlassSurfaceLevel = LiquidGlassSurfaceLevel.Panel, + backdrop: Backdrop? = null, + backdropSamplingEnabled: Boolean = false, + blurRadiusMultiplier: Float = 1f, + tintAlphaMultiplier: Float = 1f +): Modifier { + if (LocalAppUiTheme.current == AppUiTheme.MIUIX) { + val miuixShape = SmoothRoundedCornerShape( + when (level) { + LiquidGlassSurfaceLevel.Control -> 12.dp + LiquidGlassSurfaceLevel.Panel -> 16.dp + LiquidGlassSurfaceLevel.Floating -> 20.dp + } + ) + val miuixColor = when (level) { + LiquidGlassSurfaceLevel.Control -> MiuixTheme.colorScheme.secondaryVariant + LiquidGlassSurfaceLevel.Panel -> MiuixTheme.colorScheme.surfaceContainer + LiquidGlassSurfaceLevel.Floating -> MiuixTheme.colorScheme.surfaceContainerHighest + } + return clip(miuixShape).background(miuixColor) + } + val tokens = LocalLiquidGlassTokens.current + val style = tokens.surface(level) + val renderingQuality = if (backdropSamplingEnabled) { + tokens.quality + } else if (tokens.enabled) { + LiquidGlassQuality.Tinted + } else { + LiquidGlassQuality.Disabled + } + + return when (renderingQuality) { + LiquidGlassQuality.Disabled -> + clip(shape).background(fallbackColor) + + LiquidGlassQuality.Tinted -> + clip(shape) + .background( + style.legacyTint.copy( + alpha = (style.legacyTint.alpha * tintAlphaMultiplier).coerceIn(0f, 1f) + ) + ) + .border(0.75.dp, style.outline, shape) + + LiquidGlassQuality.Blurred, + LiquidGlassQuality.Refractive -> { + val source = backdrop ?: LocalLiquidGlassAmbientBackdrop.current + val canRefract = renderingQuality.supportsRefraction && + level != LiquidGlassSurfaceLevel.Panel && + shape is CornerBasedShape && + style.refractionHeight > 0.dp && + style.refractionAmount > 0.dp + + drawBackdrop( + backdrop = source, + shape = { shape }, + effects = { + vibrancy() + blur(style.blurRadius.toPx() * blurRadiusMultiplier.coerceAtLeast(0f)) + if (canRefract) { + lens( + refractionHeight = style.refractionHeight.toPx(), + refractionAmount = style.refractionAmount.toPx() + ) + } + }, + highlight = { + Highlight.Ambient.copy(alpha = style.highlightAlpha) + }, + shadow = { + Shadow( + radius = style.shadowRadius, + color = style.shadowColor + ) + }, + onDrawSurface = { + drawRect( + style.tint.copy( + alpha = (style.tint.alpha * tintAlphaMultiplier).coerceIn(0f, 1f) + ) + ) + } + ).border(0.75.dp, style.outline, shape) + } + } +} + +/** Makes a scene transparent only while its ambient host is active. */ +@Composable +fun Modifier.appLiquidGlassSceneBackground(fallbackColor: Color): Modifier { + return background( + when (LocalAppUiTheme.current) { + AppUiTheme.MIUIX -> MiuixTheme.colorScheme.surface + AppUiTheme.MATERIAL -> fallbackColor + AppUiTheme.LIQUID_GLASS -> if (LocalLiquidGlassTokens.current.enabled) { + Color.Transparent + } else { + fallbackColor + } + } + ) +} diff --git a/app/src/main/java/com/ahu/ahutong/ui/components/LiquidSlider.kt b/app/src/main/java/com/ahu/ahutong/ui/components/LiquidSlider.kt index 5c70c12b..7f45b93a 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/components/LiquidSlider.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/components/LiquidSlider.kt @@ -2,12 +2,15 @@ package com.ahu.ahutong.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -25,11 +28,17 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setProgress import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.fastRoundToInt import androidx.compose.ui.util.lerp +import com.ahu.ahutong.ui.theme.LiquidGlassQuality +import com.ahu.ahutong.ui.theme.LocalLiquidGlassTokens import com.ahu.ahutong.ui.utils.DampedDragAnimation import com.kyant.backdrop.Backdrop import com.kyant.backdrop.backdrops.layerBackdrop @@ -54,18 +63,49 @@ fun LiquidSlider( backdrop: Backdrop, modifier: Modifier = Modifier ) { - val isLightTheme = !isSystemInDarkTheme() - val accentColor = - if (isLightTheme) Color(0xFF0088FF) - else Color(0xFF0091FF) - val trackColor = - if (isLightTheme) Color(0xFF787878).copy(0.2f) - else Color(0xFF787880).copy(0.36f) + val tokens = LocalLiquidGlassTokens.current + val accentColor = MaterialTheme.colorScheme.primary + val trackColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + + if (!tokens.quality.supportsBlur) { + Slider( + value = value().coerceIn(valueRange), + onValueChange = onValueChange, + valueRange = valueRange, + modifier = modifier.fillMaxWidth(), + colors = SliderDefaults.colors( + thumbColor = accentColor, + activeTrackColor = accentColor, + inactiveTrackColor = if (tokens.quality == LiquidGlassQuality.Tinted) { + tokens.control.legacyTint + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + } + ) + ) + return + } val trackBackdrop = rememberLayerBackdrop() + val surfaceStyle = tokens.control BoxWithConstraints( - modifier.fillMaxWidth(), + modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .semantics { + val currentValue = value().coerceIn(valueRange) + progressBarRangeInfo = ProgressBarRangeInfo(currentValue, valueRange) + setProgress { requestedValue -> + val coercedValue = requestedValue.coerceIn(valueRange) + if (coercedValue != currentValue) { + onValueChange(coercedValue) + true + } else { + false + } + } + }, contentAlignment = Alignment.CenterStart ) { val trackWidth = constraints.maxWidth @@ -108,22 +148,29 @@ fun LiquidSlider( } } - Box(Modifier.layerBackdrop(trackBackdrop)) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .pointerInput(animationScope, trackWidth, valueRange) { + detectTapGestures { position -> + val delta = (valueRange.endInclusive - valueRange.start) * + (position.x / trackWidth) + val targetValue = + (if (isLtr) valueRange.start + delta + else valueRange.endInclusive - delta) + .coerceIn(valueRange) + dampedDragAnimation.animateToValue(targetValue) + onValueChange(targetValue) + } + } + .layerBackdrop(trackBackdrop), + contentAlignment = Alignment.Center + ) { Box( Modifier .clip(ContinuousCapsule) .background(trackColor) - .pointerInput(animationScope) { - detectTapGestures { position -> - val delta = (valueRange.endInclusive - valueRange.start) * (position.x / trackWidth) - val targetValue = - (if (isLtr) valueRange.start + delta - else valueRange.endInclusive - delta) - .coerceIn(valueRange) - dampedDragAnimation.animateToValue(targetValue) - onValueChange(targetValue) - } - } .height(6f.dp) .fillMaxWidth() ) @@ -166,12 +213,13 @@ fun LiquidSlider( shape = { ContinuousCapsule }, effects = { val progress = dampedDragAnimation.pressProgress - blur(8f.dp.toPx() * (1f - progress)) - lens( - 10f.dp.toPx() * progress, - 14f.dp.toPx() * progress, - chromaticAberration = true - ) + blur(surfaceStyle.blurRadius.toPx() * (1f - progress)) + if (tokens.quality.supportsRefraction && progress > 0f) { + lens( + surfaceStyle.refractionHeight.toPx() * progress, + surfaceStyle.refractionAmount.toPx() * progress + ) + } }, highlight = { val progress = dampedDragAnimation.pressProgress @@ -203,7 +251,8 @@ fun LiquidSlider( }, onDrawSurface = { val progress = dampedDragAnimation.pressProgress - drawRect(Color.White.copy(alpha = 1f - progress)) + drawRect(surfaceStyle.tint) + drawRect(accentColor.copy(alpha = 1f - progress)) } ) .size(40f.dp, 24f.dp) 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 9e20b62b..51dd89b2 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 @@ -2,6 +2,7 @@ package com.ahu.ahutong.ui.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.size import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults @@ -31,14 +32,21 @@ 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.contentDescription +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.onClick 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.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.lerp +import com.ahu.ahutong.ui.theme.LocalLiquidGlassTokens +import com.ahu.ahutong.data.model.AppUiTheme import com.ahu.ahutong.ui.utils.DampedDragAnimation import com.kyant.backdrop.Backdrop import com.kyant.backdrop.backdrops.layerBackdrop @@ -54,6 +62,7 @@ import com.kyant.backdrop.shadow.Shadow import com.kyant.capsule.ContinuousCapsule import kotlinx.coroutines.flow.collectLatest import kotlin.math.abs +import top.yukonga.miuix.kmp.basic.Switch as MiuixSwitch @Composable fun LiquidToggle( @@ -63,10 +72,22 @@ fun LiquidToggle( modifier: Modifier = Modifier, userInputEnabled: Boolean = true, toggleOnTap: Boolean = true, + contentDescription: String? = null, onHorizontalDragActiveChange: (Boolean) -> Unit = {} ) { - val isLiquid = LocalIsLiquidGlassEnabled.current - if (!isLiquid) { + if (LocalAppUiTheme.current == AppUiTheme.MIUIX) { + MiuixSwitch( + checked = selected(), + onCheckedChange = onSelect.takeIf { userInputEnabled && toggleOnTap }, + modifier = modifier + .heightIn(min = 48.dp) + .then(if (toggleOnTap) Modifier else Modifier.clearAndSetSemantics {}), + enabled = userInputEnabled + ) + return + } + val tokens = LocalLiquidGlassTokens.current + if (!tokens.quality.supportsBlur) { val colorScheme = MaterialTheme.colorScheme val switchColor = SwitchDefaults.colors( checkedThumbColor = colorScheme.onPrimary, @@ -82,16 +103,24 @@ fun LiquidToggle( Switch( checked = selected(), onCheckedChange = onSelect.takeIf { userInputEnabled && toggleOnTap }, - modifier = modifier.height(28f.dp), + modifier = modifier + .heightIn(min = 48.dp) + .then( + when { + !toggleOnTap -> Modifier.clearAndSetSemantics {} + contentDescription != null -> Modifier.semantics { + this.contentDescription = contentDescription + } + else -> Modifier + } + ), colors = switchColor ) return } val isLightTheme = MaterialTheme.colorScheme.surface.luminance() > 0.5f - val accentColor = - if (isLightTheme) Color(0xFF34C759) - else Color(0xFF30D158) + val accentColor = MaterialTheme.colorScheme.primary val trackColor = if (isLightTheme) Color(0xFF787878).copy(0.2f) else Color(0xFF787880).copy(0.36f) @@ -197,9 +226,32 @@ fun LiquidToggle( } val trackBackdrop = rememberLayerBackdrop() + val accessibilityModifier = if (toggleOnTap) { + Modifier.semantics { + role = Role.Switch + toggleableState = if (currentSelected.value()) { + ToggleableState.On + } else { + ToggleableState.Off + } + contentDescription?.let { this.contentDescription = it } + if (userInputEnabled) { + onClick { + currentOnSelect.value(!currentSelected.value()) + true + } + } else { + disabled() + } + } + } else { + Modifier.clearAndSetSemantics {} + } Box( - modifier, + modifier + .heightIn(min = 48.dp) + .then(accessibilityModifier), contentAlignment = Alignment.CenterStart ) { Box( @@ -224,11 +276,9 @@ fun LiquidToggle( } .then( if (userInputEnabled) { - Modifier - .semantics { role = Role.Switch } - .then(dampedDragAnimation.modifier) + Modifier.then(dampedDragAnimation.modifier) } else { - Modifier.clearAndSetSemantics { } + Modifier } ) .drawBackdrop( @@ -246,12 +296,15 @@ fun LiquidToggle( shape = { ContinuousCapsule }, effects = { val progress = dampedDragAnimation.pressProgress - blur(8f.dp.toPx() * (1f - progress)) - lens( - 5f.dp.toPx() * progress, - 10f.dp.toPx() * progress, - chromaticAberration = true - ) + if (tokens.quality.supportsBlur) { + blur(tokens.control.blurRadius.toPx() * (1f - progress)) + } + if (tokens.quality.supportsRefraction && progress > 0f) { + lens( + tokens.control.refractionHeight.toPx() * progress, + tokens.control.refractionAmount.toPx() * progress + ) + } }, highlight = { val progress = dampedDragAnimation.pressProgress diff --git a/app/src/main/java/com/ahu/ahutong/ui/components/LocalIsLiquidGlassEnabled.kt b/app/src/main/java/com/ahu/ahutong/ui/components/LocalIsLiquidGlassEnabled.kt index 338fd1c5..dd4c1fd5 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/components/LocalIsLiquidGlassEnabled.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/components/LocalIsLiquidGlassEnabled.kt @@ -1,5 +1,8 @@ package com.ahu.ahutong.ui.components import androidx.compose.runtime.compositionLocalOf +import com.ahu.ahutong.data.model.AppUiTheme -val LocalIsLiquidGlassEnabled = compositionLocalOf { true } +val LocalIsLiquidGlassEnabled = compositionLocalOf { false } + +val LocalAppUiTheme = compositionLocalOf { AppUiTheme.MATERIAL } 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 index 8a282796..a9cb27ce 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/components/SettingsComponents.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/components/SettingsComponents.kt @@ -1,20 +1,27 @@ package com.ahu.ahutong.ui.components import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.clickable +import androidx.compose.foundation.ScrollState 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize 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.navigationBarsPadding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState @@ -39,6 +46,7 @@ import androidx.compose.material3.Surface 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.mutableStateOf import androidx.compose.runtime.remember @@ -46,30 +54,62 @@ 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.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.IntRect import androidx.compose.ui.window.Dialog import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.ahu.ahutong.data.model.AppUiTheme +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel 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 +import top.yukonga.miuix.kmp.basic.BasicComponent as MiuixBasicComponent +import top.yukonga.miuix.kmp.basic.BasicComponentDefaults as MiuixBasicComponentDefaults +import top.yukonga.miuix.kmp.basic.Card as MiuixCard +import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon +import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton +import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior +import top.yukonga.miuix.kmp.basic.Scaffold as MiuixScaffold +import top.yukonga.miuix.kmp.basic.SmallTitle as MiuixSmallTitle +import top.yukonga.miuix.kmp.basic.Text as MiuixText +import top.yukonga.miuix.kmp.basic.TopAppBar as MiuixTopAppBar +import top.yukonga.miuix.kmp.extra.SuperDropdown +import top.yukonga.miuix.kmp.extra.SuperArrow +import top.yukonga.miuix.kmp.extra.SuperSwitch +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.icons.useful.Back +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.utils.PressFeedbackType +import top.yukonga.miuix.kmp.utils.scrollEndHaptic +import kotlin.math.roundToInt data class SettingsChoice( val value: T, val label: String ) +@Composable +private fun rememberThemeHapticAction(action: () -> Unit): () -> Unit { + val haptic = LocalHapticFeedback.current + val useMiuixFeedback = LocalAppUiTheme.current == AppUiTheme.MIUIX + return remember(action, haptic, useMiuixFeedback) { + { + if (useMiuixFeedback) { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + action() + } + } +} + @Composable fun SettingsDialogSurface( onDismissRequest: () -> Unit, @@ -164,99 +204,112 @@ fun SettingsBackdropContainer( modifier: Modifier = Modifier, content: @Composable BoxScope.(Backdrop) -> Unit ) { - val backdrop = rememberLayerBackdrop() - val liquid = LocalIsLiquidGlassEnabled.current + val backdrop = LocalLiquidGlassAmbientBackdrop.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)) - } - ) - ) + Box(modifier = modifier.appLiquidGlassSceneBackground(background)) { content(backdrop) } } @Composable -fun SettingsPageHeader( +fun SettingsPageLayout( title: String, modifier: Modifier = Modifier, onBack: (() -> Unit)? = null, - backdrop: Backdrop? = null + backdrop: Backdrop? = null, + scrollState: ScrollState = rememberScrollState(), + scrollEnabled: Boolean = true, + bottomPadding: androidx.compose.ui.unit.Dp = 112.dp, + content: @Composable ColumnScope.() -> Unit ) { - 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) + val uiTheme = LocalAppUiTheme.current + LaunchedEffect(uiTheme) { + scrollState.scrollTo(0) } - 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 + if (uiTheme != AppUiTheme.MIUIX) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(scrollState, enabled = scrollEnabled) + .systemBarsPadding() + .padding(bottom = bottomPadding), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + SettingsPageHeader(title = title, onBack = onBack, backdrop = backdrop) + content() + } + return + } + + val scrollBehavior = MiuixScrollBehavior() + val haptic = LocalHapticFeedback.current + val onBackWithFeedback = onBack?.let { callback -> + { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + callback() + } + } + MiuixScaffold( + modifier = modifier.fillMaxSize(), + containerColor = Color.Transparent, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + MiuixTopAppBar( + title = title, + largeTitle = title, + scrollBehavior = scrollBehavior, + navigationIcon = { + onBackWithFeedback?.let { callback -> + MiuixIconButton(onClick = callback) { + MiuixIcon( + imageVector = MiuixIcons.Useful.Back, + contentDescription = "返回", + tint = MiuixTheme.colorScheme.onSurface ) - } else { - Modifier - .clip(backShape) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) } + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection) + .scrollEndHaptic() + .verticalScroll(scrollState, enabled = scrollEnabled) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + top = paddingValues.calculateTopPadding() + 20.dp, + bottom = bottomPadding ) - .clickable(onClick = it), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = "返回", - tint = MaterialTheme.colorScheme.onSurface - ) - } + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(14.dp), + content = content + ) } - Text( - text = title, - color = MaterialTheme.colorScheme.onSurface, - style = if (onBack == null) { - MaterialTheme.typography.headlineLarge - } else { - MaterialTheme.typography.headlineMedium - }, - fontWeight = FontWeight.SemiBold - ) } } +@Composable +fun SettingsPageHeader( + title: String, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, + backdrop: Backdrop? = null +) { + AppPageHeader( + title = title, + modifier = modifier, + onBack = onBack, + backdrop = backdrop + ) +} + @Composable fun SettingsHeroCard( backdrop: Backdrop, @@ -264,27 +317,34 @@ fun SettingsHeroCard( 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) + val onClickWithFeedback = rememberThemeHapticAction(onClick) + if (LocalAppUiTheme.current == AppUiTheme.MIUIX) { + MiuixCard( + modifier = modifier.fillMaxWidth(), + cornerRadius = 16.dp, + insideMargin = PaddingValues(horizontal = 20.dp, vertical = 18.dp), + pressFeedbackType = PressFeedbackType.Sink, + onClick = onClickWithFeedback + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + content = content + ) + } + return } + val shape = SmoothRoundedCornerShape(28.dp) Row( modifier = modifier .fillMaxWidth() - .then( - if (isLiquid) { - Modifier.liquidGlassSurface(backdrop, shape, glassTint) - } else { - Modifier - .clip(shape) - .background(MaterialTheme.colorScheme.primaryContainer) - } + .appLiquidGlassSurface( + shape = shape, + fallbackColor = MaterialTheme.colorScheme.primaryContainer, + level = LiquidGlassSurfaceLevel.Floating, + backdrop = backdrop ) - .clickable(onClick = onClick) + .clickable(onClick = onClickWithFeedback) .padding(horizontal = 22.dp, vertical = 18.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, @@ -299,17 +359,24 @@ fun SettingsSection( backdrop: Backdrop? = null, content: @Composable ColumnScope.() -> Unit ) { + if (LocalAppUiTheme.current == AppUiTheme.MIUIX) { + Column(modifier = modifier.fillMaxWidth()) { + MiuixSmallTitle(text = title) + MiuixCard( + modifier = Modifier.fillMaxWidth(), + cornerRadius = 16.dp, + insideMargin = PaddingValues(0.dp) + ) { + content() + } + } + return + } 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) + verticalArrangement = Arrangement.spacedBy(6.dp) ) { Text( text = title, @@ -325,42 +392,17 @@ fun SettingsSection( Column( modifier = Modifier .fillMaxWidth() - .then( - if (isLiquid && backdrop != null) { - Modifier.liquidGlassSurface(backdrop, shape, glassTint) - } else { - Modifier - .clip(shape) - .background(settingsGroupColor()) - } + .appLiquidGlassSurface( + shape = shape, + fallbackColor = settingsGroupColor(), + level = LiquidGlassSurfaceLevel.Panel, + backdrop = backdrop ), 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, @@ -373,11 +415,53 @@ fun SettingsActionRow( showChevron: Boolean = true, showDivider: Boolean = true ) { + val onClickWithFeedback = rememberThemeHapticAction(onClick) + if (LocalAppUiTheme.current == AppUiTheme.MIUIX) { + Column(modifier = modifier.fillMaxWidth()) { + SuperArrow( + title = title, + titleColor = MiuixBasicComponentDefaults.titleColor( + color = if (destructive) { + MaterialTheme.colorScheme.error + } else { + MiuixTheme.colorScheme.onBackground + } + ), + summary = subtitle, + leftAction = leadingIcon?.let { icon -> + { + MiuixIcon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.padding(end = 16.dp).size(24.dp), + tint = if (destructive) { + MaterialTheme.colorScheme.error + } else { + MiuixTheme.colorScheme.primary + } + ) + } + }, + rightActions = { + value?.let { + MiuixText( + text = it, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary + ) + } + }, + modifier = Modifier.fillMaxWidth(), + onClick = onClickWithFeedback + ) + SettingsDivider(visible = showDivider) + } + return + } Column(modifier = modifier.fillMaxWidth()) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .clickable(onClick = onClickWithFeedback) .heightIn(min = 68.dp) .padding(horizontal = 20.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(14.dp), @@ -432,6 +516,25 @@ fun SettingsInfoRow( value: String? = null, showDivider: Boolean = true ) { + if (LocalAppUiTheme.current == AppUiTheme.MIUIX) { + Column(modifier = modifier.fillMaxWidth()) { + MiuixBasicComponent( + title = title, + summary = subtitle, + modifier = Modifier.fillMaxWidth(), + rightActions = { + value?.let { + MiuixText( + text = it, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary + ) + } + } + ) + SettingsDivider(visible = showDivider) + } + return + } Column(modifier = modifier.fillMaxWidth()) { Row( modifier = Modifier @@ -470,6 +573,25 @@ fun SettingsToggleRow( showDivider: Boolean = true, onHorizontalDragActiveChange: (Boolean) -> Unit = {} ) { + if (LocalAppUiTheme.current == AppUiTheme.MIUIX) { + val haptic = LocalHapticFeedback.current + val onCheckedWithFeedback: (Boolean) -> Unit = { checked -> + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onSelectedChange(checked) + } + Column(modifier = modifier.fillMaxWidth()) { + SuperSwitch( + checked = selected, + onCheckedChange = onCheckedWithFeedback, + title = title, + summary = subtitle, + modifier = Modifier.fillMaxWidth(), + enabled = enabled + ) + SettingsDivider(visible = showDivider) + } + return + } Column(modifier = modifier.fillMaxWidth()) { Row( modifier = Modifier @@ -515,23 +637,41 @@ fun SettingsSelectRow( 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() - ) { + if (LocalAppUiTheme.current == AppUiTheme.MIUIX) { + val selectedIndex = choices.indexOfFirst { it.value == selected }.coerceAtLeast(0) + val haptic = LocalHapticFeedback.current + Column(modifier = modifier.fillMaxWidth()) { + SuperDropdown( + items = choices.map(SettingsChoice::label), + selectedIndex = selectedIndex, + title = title, + summary = subtitle, + modifier = Modifier.fillMaxWidth(), + onSelectedIndexChange = { index -> + choices.getOrNull(index)?.let { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onSelected(it.value) + } + } + ) + SettingsDivider(visible = showDivider) + } + return + } + if (LocalAppUiTheme.current == AppUiTheme.LIQUID_GLASS) { + var expanded by remember { mutableStateOf(false) } + var anchorBounds by remember { mutableStateOf(IntRect(0, 0, 0, 0)) } + val selectedLabel = choices.firstOrNull { it.value == selected }?.label.orEmpty() + val popupWidth = LocalConfiguration.current.screenWidthDp.dp * 0.5f + val popupOptions = remember(choices) { + choices.map { choice -> AppSelectOption(choice.value, choice.label) } + } + Column(modifier = modifier.fillMaxWidth()) { Row( modifier = Modifier .fillMaxWidth() - .menuAnchor( - type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, - enabled = true - ) .heightIn(min = 68.dp) - .padding(horizontal = 20.dp, vertical = 12.dp), + .padding(horizontal = 20.dp, vertical = 10.dp), horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -540,50 +680,160 @@ fun SettingsSelectRow( subtitle = subtitle, modifier = Modifier.weight(1f) ) - Text( - text = selectedLabel, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyLarge - ) - ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + Box { + Row( + modifier = Modifier + .onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + anchorBounds = IntRect( + left = bounds.left.roundToInt(), + top = bounds.top.roundToInt(), + right = bounds.right.roundToInt(), + bottom = bounds.bottom.roundToInt() + ) + } + .heightIn(min = 48.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + role = Role.Button + ) { expanded = !expanded } + .padding(horizontal = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = selectedLabel, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge + ) + LiquidGlassDropdownIndicator( + expanded = expanded, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + LiquidGlassDropdownPopup( + expanded = expanded, + anchorBoundsInWindow = anchorBounds, + popupWidth = popupWidth, + selected = selected, + options = popupOptions, + onSelected = onSelected, + onDismiss = { expanded = false } + ) + } } - ExposedDropdownMenu( + SettingsDivider(visible = showDivider) + } + return + } + var expanded by remember { mutableStateOf(false) } + val isLiquidGlass = LocalAppUiTheme.current == AppUiTheme.LIQUID_GLASS + val selectedLabel = choices.firstOrNull { it.value == selected }?.label.orEmpty() + val menuMinWidth = LocalConfiguration.current.screenWidthDp.dp * 0.5f + 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) + ) + ExposedDropdownMenuBox( expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh) + onExpandedChange = { expanded = !expanded } ) { - 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 + Row( + modifier = Modifier + .menuAnchor( + type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, + enabled = true + ) + .heightIn(min = 48.dp) + .then( + if (isLiquidGlass) { + Modifier + .clip(SmoothRoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.32f)) + } else { + Modifier + } + ) + .padding(horizontal = 10.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = selectedLabel, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge + ) + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + } + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier + .widthIn(min = menuMinWidth) + .then( + if (isLiquidGlass) { + Modifier.appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(20.dp), + fallbackColor = MaterialTheme.colorScheme.surfaceContainer, + level = LiquidGlassSurfaceLevel.Floating, + backdrop = LocalLiquidGlassContentBackdrop.current, + backdropSamplingEnabled = false ) - ) - }, - trailingIcon = { - if (choice.value == selected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary + } else { + Modifier + } + ), + matchAnchorWidth = false, + containerColor = if (isLiquidGlass) { + Color.Transparent + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + tonalElevation = 0.dp + ) { + choices.forEach { choice -> + val isSelected = choice.value == selected + DropdownMenuItem( + text = { + Text( + text = choice.label, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge ) + }, + trailingIcon = { + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + modifier = Modifier.background( + if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.10f) + } else { + Color.Transparent + } + ), + onClick = { + onSelected(choice.value) + expanded = false } - }, - onClick = { - onSelected(choice.value) - expanded = false - } - ) + ) + } } } } @@ -705,6 +955,7 @@ private fun SettingsDivider( visible: Boolean, leadingInset: androidx.compose.ui.unit.Dp = 20.dp ) { + if (LocalAppUiTheme.current == AppUiTheme.MIUIX) return if (visible) { HorizontalDivider( modifier = Modifier.padding(start = leadingInset), diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/ApkUpdateDialog.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/ApkUpdateDialog.kt index 42ed488e..142442eb 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/ApkUpdateDialog.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/ApkUpdateDialog.kt @@ -31,8 +31,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import com.ahu.ahutong.data.server.model.ApkUpdateInfo +import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.ApkDownloadSegment +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.withNight @@ -59,8 +61,15 @@ fun ApkUpdateDialog( val progressColor = 70.a1 withNight 80.a1 val progressTrackColor = 92.n1 withNight 30.n1 val activeSegmentColor = 80.a1.copy(alpha = 0.45f) withNight 55.a1.copy(alpha = 0.65f) + val dialogShape = SmoothRoundedCornerShape(32.dp) AlertDialog( + modifier = Modifier.appLiquidGlassSurface( + shape = dialogShape, + fallbackColor = containerColor, + level = LiquidGlassSurfaceLevel.Floating, + backdropSamplingEnabled = false + ), onDismissRequest = { if (!info.force && !downloading) onDismiss() }, @@ -179,8 +188,8 @@ fun ApkUpdateDialog( } } }, - shape = SmoothRoundedCornerShape(32.dp), - containerColor = containerColor, + shape = dialogShape, + containerColor = Color.Transparent, confirmButton = { FilledTonalButton( onClick = if (apkLocalReady && !downloading) onInstallLocal else onConfirm, @@ -245,8 +254,15 @@ fun ApkMirrorSourceDialog( ) { val contentColor = 10.n1 withNight 90.n1 val containerColor = 100.n1 withNight 20.n1 + val dialogShape = SmoothRoundedCornerShape(32.dp) AlertDialog( + modifier = Modifier.appLiquidGlassSurface( + shape = dialogShape, + fallbackColor = containerColor, + level = LiquidGlassSurfaceLevel.Floating, + backdropSamplingEnabled = false + ), onDismissRequest = onKeepOriginal, properties = DialogProperties( dismissOnBackPress = true, @@ -267,8 +283,8 @@ fun ApkMirrorSourceDialog( color = contentColor ) }, - shape = SmoothRoundedCornerShape(32.dp), - containerColor = containerColor, + shape = dialogShape, + containerColor = Color.Transparent, confirmButton = { FilledTonalButton( onClick = onUseMirror, 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 718fc8cc..4406b4b2 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 @@ -6,29 +6,33 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.TableChart import androidx.compose.material.icons.outlined.Build import androidx.compose.material.icons.outlined.Home 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.NavigationBar as MaterialNavigationBar 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.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.ahu.ahutong.ui.components.LocalAppUiTheme +import com.ahu.ahutong.data.model.AppUiTheme import com.kyant.backdrop.Backdrop +import top.yukonga.miuix.kmp.basic.NavigationBar as MiuixNavigationBar +import top.yukonga.miuix.kmp.basic.NavigationItem as MiuixNavigationItem private data class BottomDestination( val route: String, @@ -38,19 +42,18 @@ private data class BottomDestination( ) 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) + BottomDestination("home", "主页", Icons.Filled.Home, Icons.Outlined.Home), + BottomDestination("schedule", "课表", Icons.Filled.TableChart, Icons.Outlined.TableChart), + BottomDestination("tools", "小工具", Icons.Filled.Build, Icons.Outlined.Build), + BottomDestination("settings", "设置", Icons.Filled.Settings, Icons.Outlined.Settings) ) @Composable fun BoxScope.BottomNavBar( - navController: NavHostController, - backdrop: Backdrop + backdrop: Backdrop, + selectedRoute: String?, + onDestinationSelected: (String) -> Unit ) { - val currentRoute by navController.currentBackStackEntryAsState() - val selectedRoute = currentRoute?.destination?.route if (selectedRoute !in bottomDestinations.map { it.route }) return if (LocalIsLiquidGlassEnabled.current) { @@ -66,7 +69,7 @@ fun BoxScope.BottomNavBar( bottomDestinations.indexOfFirst { it.route == selectedRoute }.coerceAtLeast(0) }, onTabSelected = { index -> - navController.navigatePreservingHome(bottomDestinations[index].route) + onDestinationSelected(bottomDestinations[index].route) }, backdrop = backdrop, tabsCount = bottomDestinations.size, @@ -75,8 +78,9 @@ fun BoxScope.BottomNavBar( bottomDestinations.forEach { destination -> val selected = selectedRoute == destination.route LiquidBottomTab( + selected = selected, onClick = { - navController.navigatePreservingHome(destination.route) + onDestinationSelected(destination.route) } ) { Icon( @@ -95,8 +99,31 @@ fun BoxScope.BottomNavBar( } } } + } else if (LocalAppUiTheme.current == AppUiTheme.MIUIX) { + val selectedIndex = bottomDestinations + .indexOfFirst { it.route == selectedRoute } + .coerceAtLeast(0) + MiuixNavigationBar( + items = bottomDestinations.mapIndexed { index, destination -> + MiuixNavigationItem( + label = destination.label, + icon = if (index == selectedIndex) { + destination.selectedIcon + } else { + destination.unselectedIcon + } + ) + }, + selected = selectedIndex, + onClick = { index -> + onDestinationSelected(bottomDestinations[index].route) + }, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + ) } else { - NavigationBar( + MaterialNavigationBar( modifier = Modifier .fillMaxWidth() .align(Alignment.BottomCenter), @@ -107,7 +134,7 @@ fun BoxScope.BottomNavBar( val selected = selectedRoute == destination.route NavigationBarItem( selected = selected, - onClick = { navController.navigatePreservingHome(destination.route) }, + onClick = { onDestinationSelected(destination.route) }, icon = { Icon( imageVector = if (selected) { @@ -131,11 +158,3 @@ fun BoxScope.BottomNavBar( } } } - -private fun NavController.navigatePreservingHome(route: String) { - 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/HotUpdateDialog.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/HotUpdateDialog.kt index 0866187f..37fb3f45 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/HotUpdateDialog.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/HotUpdateDialog.kt @@ -11,10 +11,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties +import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.withNight @@ -26,8 +29,15 @@ fun HotUpdateDialog( ) { val contentColor = 10.n1 withNight 90.n1 val containerColor = 100.n1 withNight 20.n1 + val dialogShape = SmoothRoundedCornerShape(32.dp) AlertDialog( + modifier = Modifier.appLiquidGlassSurface( + shape = dialogShape, + fallbackColor = containerColor, + level = LiquidGlassSurfaceLevel.Floating, + backdropSamplingEnabled = false + ), onDismissRequest = { }, @@ -60,8 +70,8 @@ fun HotUpdateDialog( color = contentColor ) }, - shape = SmoothRoundedCornerShape(32.dp), - containerColor = containerColor, + shape = dialogShape, + containerColor = Color.Transparent, confirmButton = { if (!isDownloading) { FilledTonalButton( @@ -78,4 +88,4 @@ fun HotUpdateDialog( } } ) -} \ No newline at end of file +} 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 98b29868..6f086bbc 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 @@ -1,8 +1,7 @@ package com.ahu.ahutong.ui.screen +import com.ahu.ahutong.BuildConfig import androidx.compose.animation.ExperimentalAnimationApi -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.Column @@ -12,6 +11,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.animation.core.tween import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -20,19 +22,18 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavType import androidx.navigation.NavHostController import androidx.navigation.navArgument @@ -43,8 +44,8 @@ import com.ahu.ahutong.data.gray.GrayFeatures import com.ahu.ahutong.data.gray.GrayReleaseManager import com.ahu.ahutong.ui.screen.main.BathroomDeposit import com.ahu.ahutong.ui.screen.main.CardBalanceDeposit -import com.ahu.ahutong.ui.screen.main.CmbCardRecharge import com.ahu.ahutong.ui.screen.main.ElectricityDeposit +import com.ahu.ahutong.ui.screen.main.ElectricityRecentRooms import com.ahu.ahutong.ui.screen.main.Evaluation import com.ahu.ahutong.ui.screen.main.Exam import com.ahu.ahutong.ui.screen.main.FreeClassroom @@ -69,20 +70,24 @@ import com.ahu.ahutong.ui.screen.settings.License import com.ahu.ahutong.ui.screen.settings.Preferences import com.ahu.ahutong.ui.screen.setup.Info import com.ahu.ahutong.ui.screen.setup.Login -import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.ahu.ahutong.ui.components.LiquidGlassAppHost +import com.ahu.ahutong.ui.components.LocalAppUiTheme +import com.ahu.ahutong.ui.components.LocalLiquidGlassContentBackdrop +import com.ahu.ahutong.ui.components.AppButton +import com.ahu.ahutong.ui.components.AppDialogSurface +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.captureLiquidGlassContent import com.ahu.ahutong.ui.state.AboutViewModel import com.ahu.ahutong.ui.state.DiscoveryViewModel +import com.ahu.ahutong.ui.state.ElectricityDepositViewModel import com.ahu.ahutong.ui.state.LoginViewModel import com.ahu.ahutong.ui.state.MainViewModel import com.ahu.ahutong.ui.state.ScheduleViewModel import com.ahu.ahutong.utils.animatedComposable -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import com.kyant.capsule.ContinuousCapsule -import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.withNight import kotlinx.coroutines.launch +import kotlinx.coroutines.delay import com.ahu.ahutong.personalization.action.ActionSource import com.ahu.ahutong.personalization.diagnostics.DiagnosticsContribution import com.ahu.ahutong.personalization.prefetch.PaymentQrOpenCommandStore @@ -90,6 +95,8 @@ import com.ahu.ahutong.personalization.runtime.BehaviorPredictionRuntime import com.ahu.ahutong.personalization.ui.SmartSuggestionHost import com.ahu.ahutong.personalization.action.AppActionId +private val primaryDestinationRoutes = listOf("home", "schedule", "tools", "settings") + @OptIn(ExperimentalAnimationApi::class, ExperimentalLayoutApi::class) @Composable fun Main( @@ -112,19 +119,42 @@ fun Main( mutableStateOf(GrayReleaseManager.localState(GrayFeatures.HomeEdit, context)) } var firstDestination by remember { mutableStateOf(true) } - var lastBackStackDepth by remember { mutableIntStateOf(0) } + var lastRoute by remember { mutableStateOf(null) } val currentBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = currentBackStackEntry?.destination?.route - val currentBackStack by navController.currentBackStack.collectAsState() - val currentBackStackDepth = currentBackStack.size val suggestionOverlayBlocked by behaviorRuntime.suggestionOverlayBlocked.collectAsState() val imeVisible = WindowInsets.isImeVisible val diagnosticsRouteVisible = diagnosticsContribution.isDiagnosticsRoute(currentRoute) || currentRoute == "debug" + val appUiTheme = LocalAppUiTheme.current + val appUiThemeState = rememberUpdatedState(appUiTheme) + val primaryPagerState = rememberPagerState(pageCount = { primaryDestinationRoutes.size }) + var preloadPrimaryNeighbors by remember { mutableStateOf(false) } + val primaryRoute = primaryDestinationRoutes[primaryPagerState.currentPage] + val effectiveRoute = if (currentRoute == "home") primaryRoute else currentRoute - LaunchedEffect(currentRoute, currentBackStackDepth) { - val route = currentRoute ?: return@LaunchedEffect - val isBackStackRestore = lastBackStackDepth > 0 && currentBackStackDepth < lastBackStackDepth + suspend fun selectPrimaryDestination(route: String) { + val destinationIndex = primaryDestinationRoutes.indexOf(route) + if (destinationIndex < 0 || destinationIndex == primaryPagerState.currentPage) return + primaryPagerState.animateScrollToPage( + page = destinationIndex, + animationSpec = tween(durationMillis = 260) + ) + } + + LaunchedEffect(currentRoute) { + if (currentRoute == "home") { + delay(1_500L) + preloadPrimaryNeighbors = true + } + } + + LaunchedEffect(effectiveRoute) { + val route = effectiveRoute ?: return@LaunchedEffect + val previousRoute = navController.previousBackStackEntry?.destination?.route + val isBackStackRestore = !firstDestination && + lastRoute != null && + previousRoute != lastRoute behaviorRuntime.onRouteChanged( route, when { @@ -134,37 +164,73 @@ fun Main( } ) firstDestination = false - lastBackStackDepth = currentBackStackDepth + lastRoute = route } LaunchedEffect(Unit) { homeEditGrayState = GrayReleaseManager.state(GrayFeatures.HomeEdit, context) } - Box { - val backdrop = rememberLayerBackdrop() + LiquidGlassAppHost(modifier = Modifier.fillMaxSize()) { + val backdrop = LocalLiquidGlassContentBackdrop.current NavHost( navController = navController, startDestination = "splash", modifier = Modifier - .layerBackdrop(backdrop) + .captureLiquidGlassContent() .fillMaxSize() - .background(96.n1 withNight 10.n1) + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1) ) { - animatedComposable("home") { - Home( - discoveryViewModel = discoveryViewModel, - scheduleViewModel = scheduleViewModel, - navController = navController, - behaviorRuntime = behaviorRuntime, - homeEditEnabled = homeEditGrayState.enabled, - enterEditModeRequest = shouldEnterHomeEdit, - onEnterEditModeRequestConsumed = { - shouldEnterHomeEdit = false + animatedComposable(appUiThemeState, "home") { + HorizontalPager( + state = primaryPagerState, + modifier = Modifier.fillMaxSize(), + beyondViewportPageCount = if (preloadPrimaryNeighbors) 1 else 0, + userScrollEnabled = false, + key = primaryDestinationRoutes::get + ) { page -> + when (page) { + 0 -> Home( + discoveryViewModel = discoveryViewModel, + scheduleViewModel = scheduleViewModel, + navController = navController, + behaviorRuntime = behaviorRuntime, + onOpenSchedule = { + scope.launch { selectPrimaryDestination("schedule") } + }, + homeEditEnabled = homeEditGrayState.enabled, + enterEditModeRequest = shouldEnterHomeEdit, + onEnterEditModeRequestConsumed = { + shouldEnterHomeEdit = false + } + ) + 1 -> Schedule( + scheduleViewModel = scheduleViewModel, + behaviorRuntime = behaviorRuntime + ) + 2 -> Tools( + navController = navController, + homeEditEnabled = homeEditGrayState.enabled, + onEditHome = { + behaviorRuntime.recordActionIntentAsync( + AppActionId.EDIT_HOME, + ActionSource.ORGANIC + ) + shouldEnterHomeEdit = true + scope.launch { selectPrimaryDestination("home") } + } + ) + 3 -> Settings( + navController = navController, + mainViewModel = mainViewModel, + aboutViewModel = aboutViewModel, + scheduleViewModel = scheduleViewModel, + behaviorRuntime = behaviorRuntime + ) } - ) + } } - animatedComposable("setup") { + animatedComposable(appUiThemeState, "setup") { Setup( scheduleViewModel = scheduleViewModel, aboutViewModel = aboutViewModel, @@ -181,12 +247,16 @@ fun Main( } ) } - animatedComposable("login") { + animatedComposable(appUiThemeState, "login") { Login( loginViewModel = loginViewModel, onLoggedIn = { scheduleViewModel.clear() scope.launch { + primaryPagerState.scrollToPage(0) + navController.navigate("home") { + popUpTo("login") { inclusive = true } + } com.ahu.ahutong.data.dao.AHUCache.getCurrentUser()?.xh?.takeIf { it.isNotBlank() }?.let { behaviorRuntime.startProfile(it) } @@ -195,63 +265,60 @@ fun Main( context ) } - navController.navigate("home") { - popUpTo("login") { inclusive = true } - } discoveryViewModel.loadActivityBean() scheduleViewModel.loadConfig() scheduleViewModel.refreshSchedule() } ) } - animatedComposable("info") { + animatedComposable(appUiThemeState, "info") { Info( scheduleViewModel = scheduleViewModel, onSetup = { navController.popBackStack() } ) } - animatedComposable("schedule") { - Schedule(scheduleViewModel = scheduleViewModel, behaviorRuntime = behaviorRuntime) + animatedComposable(appUiThemeState, "schedule") { + PrimaryDestinationRedirect( + navController = navController, + onRedirect = { primaryPagerState.scrollToPage(1) } + ) } - animatedComposable("tools") { - Tools( + animatedComposable(appUiThemeState, "tools") { + PrimaryDestinationRedirect( navController = navController, - homeEditEnabled = homeEditGrayState.enabled, - onEditHome = { - behaviorRuntime.recordActionIntentAsync(AppActionId.EDIT_HOME, ActionSource.ORGANIC) - shouldEnterHomeEdit = true - } + onRedirect = { primaryPagerState.scrollToPage(2) } ) } - animatedComposable("school_calendar") { + animatedComposable(appUiThemeState, "school_calendar") { SchoolCalendar(navController = navController) } - animatedComposable("grade") { + animatedComposable(appUiThemeState, "grade") { Grade( onNavigateToEvaluation = { navController.navigate("evaluation") - } + }, + onBack = { navController.popBackStack() } ) } - animatedComposable("phone_book") { - PhoneBook() + animatedComposable(appUiThemeState, "phone_book") { + PhoneBook(onBack = { navController.popBackStack() }) } - animatedComposable("exam") { - Exam() + animatedComposable(appUiThemeState, "exam") { + Exam(onBack = { navController.popBackStack() }) } - animatedComposable("evaluation") { - Evaluation() + animatedComposable(appUiThemeState, "evaluation") { + Evaluation(onBack = { navController.popBackStack() }) } - animatedComposable("free_classroom") { - FreeClassroom() + animatedComposable(appUiThemeState, "free_classroom") { + FreeClassroom(onBack = { navController.popBackStack() }) } - animatedComposable("lost_found") { - LostFound() + animatedComposable(appUiThemeState, "lost_found") { + LostFound(onBack = { navController.popBackStack() }) } - animatedComposable("weather") { - Weather() + animatedComposable(appUiThemeState, "weather") { + Weather(onBack = { navController.popBackStack() }) } - animatedComposable(REPOSITORY_ROUTE) { + animatedComposable(appUiThemeState, REPOSITORY_ROUTE) { Repository( navController = navController, path = "", @@ -259,6 +326,7 @@ fun Main( ) } animatedComposable( + appUiThemeState, route = REPOSITORY_DIRECTORY_ROUTE, arguments = listOf( navArgument(REPOSITORY_PATH_ARG) { @@ -274,93 +342,104 @@ fun Main( behaviorRuntime = behaviorRuntime ) } - animatedComposable("repository_downloads") { + animatedComposable(appUiThemeState, "repository_downloads") { RepositoryDownloads(navController = navController) } - animatedComposable("repository_settings") { + animatedComposable(appUiThemeState, "repository_settings") { RepositorySettings(navController = navController) } - animatedComposable("settings") { - Settings( + animatedComposable(appUiThemeState, "settings") { + PrimaryDestinationRedirect( navController = navController, - mainViewModel = mainViewModel, - aboutViewModel = aboutViewModel, - behaviorRuntime = behaviorRuntime + onRedirect = { primaryPagerState.scrollToPage(3) } ) } - animatedComposable("settings__license") { - License() + animatedComposable(appUiThemeState, "settings__license") { + License(onBack = { navController.popBackStack() }) } - animatedComposable("settings__contributors") { - Contributors() + animatedComposable(appUiThemeState, "settings__contributors") { + Contributors(onBack = { navController.popBackStack() }) } - animatedComposable("preferences") { + animatedComposable(appUiThemeState, "preferences") { Preferences(onBack = { navController.popBackStack() }) } - animatedComposable("electricity_pay") { - ElectricityDeposit() + animatedComposable(appUiThemeState, "electricity_pay") { + ElectricityDeposit( + onBack = { navController.popBackStack() }, + onOpenRecentRooms = { navController.navigate("electricity_recent_rooms") } + ) + } + + animatedComposable(appUiThemeState, "electricity_recent_rooms") { backStackEntry -> + val parentEntry = remember(backStackEntry) { + navController.getBackStackEntry("electricity_pay") + } + val electricityViewModel: ElectricityDepositViewModel = hiltViewModel(parentEntry) + ElectricityRecentRooms( + onBack = { navController.popBackStack() }, + onRoomSelected = { navController.popBackStack() }, + viewModel = electricityViewModel + ) } - animatedComposable("card_balance_deposit") { + animatedComposable(appUiThemeState, "card_balance_deposit") { CardBalanceDeposit(navController = navController) } - animatedComposable("bathroom_deposit") { - BathroomDeposit() + animatedComposable(appUiThemeState, "bathroom_deposit") { + BathroomDeposit(onBack = { navController.popBackStack() }) } - animatedComposable("cmb_card_recharge") { - 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(appUiThemeState, "cmb_card_recharge") { + CardBalanceDeposit(navController = navController) } - animatedComposable("network_recharge") { - NetworkRecharge() + animatedComposable(appUiThemeState, "network_recharge") { + NetworkRecharge(onBack = { navController.popBackStack() }) } - animatedComposable("debug") { - Debug( - scheduleViewModel = scheduleViewModel, - discoveryViewModel = discoveryViewModel, - onGrayStateChanged = { - scope.launch { - homeEditGrayState = GrayReleaseManager.state( - GrayFeatures.HomeEdit, - context - ) + if (BuildConfig.DEBUG) { + animatedComposable(appUiThemeState, "debug") { + Debug( + scheduleViewModel = scheduleViewModel, + discoveryViewModel = discoveryViewModel, + onGrayStateChanged = { + scope.launch { + homeEditGrayState = GrayReleaseManager.state( + GrayFeatures.HomeEdit, + context + ) + } } - } - ) + ) + } } - animatedComposable("splash") { + animatedComposable(appUiThemeState, "splash") { Splash(navController) } diagnosticsContribution.installRoutes(this, navController, behaviorRuntime) } - BottomNavBar(navController, backdrop) - val productUiBlocked = currentRoute == "login" || currentRoute == "setup" || - currentRoute == "splash" || currentRoute?.contains("deposit") == true || - currentRoute?.contains("recharge") == true || currentRoute == "electricity_pay" || + BottomNavBar( + backdrop = backdrop, + selectedRoute = primaryRoute.takeIf { currentRoute == "home" }, + onDestinationSelected = { route -> + scope.launch { selectPrimaryDestination(route) } + } + ) + val productUiBlocked = effectiveRoute == "login" || effectiveRoute == "setup" || + effectiveRoute == "splash" || effectiveRoute?.contains("deposit") == true || + effectiveRoute?.contains("recharge") == true || + effectiveRoute in setOf("electricity_pay", "electricity_recent_rooms") || isReLoginShown || suggestionOverlayBlocked || imeVisible SmartSuggestionHost( runtime = behaviorRuntime, backdrop = backdrop, blocked = productUiBlocked, hiddenForDiagnostics = diagnosticsRouteVisible, - bottomSpacing = if (currentRoute in setOf("home", "schedule", "tools", "settings")) { + bottomSpacing = if (effectiveRoute in primaryDestinationRoutes) { 88.dp } else { 16.dp @@ -378,7 +457,17 @@ fun Main( navController.navigate("home") { launchSingleTop = true } } else { com.ahu.ahutong.personalization.action.AppActionCatalog.spec(action).route?.let { route -> - navController.navigate(route) { launchSingleTop = true } + if (route in primaryDestinationRoutes) { + if (currentRoute != "home") { + navController.navigate("home") { + popUpTo("home") { inclusive = false } + launchSingleTop = true + } + } + selectPrimaryDestination(route) + } else { + navController.navigate(route) { launchSingleTop = true } + } } } } @@ -388,43 +477,52 @@ fun Main( with(diagnosticsContribution) { Overlay(navController, behaviorRuntime, productUiBlocked) } - } - if (isReLoginShown) { - Dialog( - onDismissRequest = { onReLoginDismiss() }, - properties = DialogProperties( - dismissOnBackPress = false, - dismissOnClickOutside = false - ) - ) { - Column( - modifier = Modifier - .clip(SmoothRoundedCornerShape(32.dp)) - .background(96.n1 withNight 10.n1) - .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 + if (isReLoginShown) { + AppDialogSurface( + onDismissRequest = { onReLoginDismiss() }, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false, + usePlatformDefaultWidth = false ) - Text( - text = "重新登录", - modifier = Modifier - .padding(horizontal = 16.dp) - .clip(ContinuousCapsule) - .background(90.a1 withNight 30.n1) - .clickable { + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Text( + text = "当前登录状态已过期,请重新登录!", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleLarge + ) + AppButton( + onClick = { navController.navigate("login") onReLoginDismiss() - } - .padding(12.dp, 8.dp), - color = 100.n1 withNight 100.n1, - style = MaterialTheme.typography.titleMedium - ) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("重新登录", style = MaterialTheme.typography.titleMedium) + } + } + } + } + } +} + +@Composable +private fun PrimaryDestinationRedirect( + navController: NavHostController, + onRedirect: suspend () -> Unit +) { + LaunchedEffect(Unit) { + onRedirect() + if (!navController.popBackStack("home", inclusive = false)) { + navController.navigate("home") { + popUpTo(navController.graph.startDestinationId) { inclusive = false } + launchSingleTop = true } } } + Box(modifier = Modifier.fillMaxSize()) } 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 81f21c68..6e0beec4 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 @@ -1,5 +1,6 @@ package com.ahu.ahutong.ui.screen +import com.ahu.ahutong.BuildConfig import android.annotation.SuppressLint import android.content.Intent import android.widget.Toast @@ -37,10 +38,12 @@ 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.livedata.observeAsState 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.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -53,6 +56,7 @@ import com.ahu.ahutong.AHUApplication import com.ahu.ahutong.R import com.ahu.ahutong.data.dao.AHUCache import com.ahu.ahutong.data.dao.PreferencesManager +import com.ahu.ahutong.data.model.AppUiTheme import com.ahu.ahutong.data.crawler.manager.CookieManager import com.ahu.ahutong.data.server.AhuTong import com.ahu.ahutong.notification.CourseReminderScheduler @@ -64,13 +68,24 @@ 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.LocalAppUiTheme +import com.ahu.ahutong.ui.components.SettingsPageLayout import com.ahu.ahutong.ui.components.SettingsSection +import com.ahu.ahutong.ui.components.appLiquidGlassSurface 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.ui.state.ScheduleViewModel +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.capsule.ContinuousCapsule import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.icons.useful.Delete +import top.yukonga.miuix.kmp.icon.icons.useful.Edit +import top.yukonga.miuix.kmp.icon.icons.useful.Info +import top.yukonga.miuix.kmp.icon.icons.useful.Personal +import top.yukonga.miuix.kmp.icon.icons.useful.Settings +import top.yukonga.miuix.kmp.icon.icons.useful.Update @SuppressLint("ContextCastToActivity") @Composable @@ -78,6 +93,7 @@ fun Settings( navController: NavHostController, mainViewModel: MainViewModel = viewModel(), aboutViewModel: AboutViewModel = viewModel(), + scheduleViewModel: ScheduleViewModel = viewModel(), behaviorRuntime: BehaviorPredictionRuntime ) { val context = LocalContext.current as ComponentActivity @@ -91,6 +107,8 @@ fun Settings( val tip by remember { aboutViewModel.tipState } var appCardTapCount by remember { mutableIntStateOf(0) } var lastAppCardTap by remember { mutableLongStateOf(0L) } + val scheduleConfig by scheduleViewModel.scheduleConfig.observeAsState() + val useMiuixIcons = LocalAppUiTheme.current == AppUiTheme.MIUIX LaunchedEffect(tip) { tip?.let { @@ -102,27 +120,25 @@ fun Settings( .onFailure { updateLog = "获取失败" } } - 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") + val onAppCardClick: () -> Unit = if (BuildConfig.DEBUG) { + { + val now = System.currentTimeMillis() + appCardTapCount = if (now - lastAppCardTap > 1_000L) 1 else appCardTapCount + 1 + lastAppCardTap = now + if (appCardTapCount >= 8) { + appCardTapCount = 0 + navController.navigate("debug") + } } + } else { + {} } SettingsBackdropContainer(modifier = Modifier.fillMaxSize()) { backdrop -> - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .systemBarsPadding() - .padding(bottom = 112.dp), - verticalArrangement = Arrangement.spacedBy(26.dp) + SettingsPageLayout( + title = stringResource(id = R.string.setting), + backdrop = backdrop ) { - SettingsPageHeader(title = stringResource(id = R.string.setting)) - val isLiquid = LocalIsLiquidGlassEnabled.current val heroContentColor = if (isLiquid) { MaterialTheme.colorScheme.onSurface @@ -141,7 +157,7 @@ fun Settings( modifier = Modifier .size(64.dp) .clip(ContinuousCapsule) - .background(MaterialTheme.colorScheme.surface) + .background(Color.White) .scale(1.65f) ) Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { @@ -160,9 +176,9 @@ fun Settings( } AHUCache.getCurrentUser()?.let { user -> - val schoolTerm = AHUCache.getSchoolTerm()?.split('-') - ?.takeIf { it.size == 3 } - ?.let { "${it[0]}-${it[1]} 学年 · 第 ${it[2]} 学期" } + val schoolTerm = remember(scheduleConfig) { + "${scheduleViewModel.schoolYear} 学年 · 第 ${scheduleViewModel.schoolTerm} 学期" + } SettingsSection( title = "账户", modifier = Modifier.padding(horizontal = 16.dp), @@ -174,7 +190,11 @@ fun Settings( ) SettingsActionRow( title = "重新登录", - leadingIcon = Icons.AutoMirrored.Outlined.Login, + leadingIcon = if (useMiuixIcons) { + MiuixIcons.Useful.Personal + } else { + Icons.AutoMirrored.Outlined.Login + }, showDivider = false, onClick = { navController.navigate("login") } ) @@ -188,13 +208,12 @@ fun Settings( ) { SettingsActionRow( title = stringResource(id = R.string.preferences), - subtitle = "通知、外观、主页与智能体验", - leadingIcon = Icons.Outlined.Tune, + leadingIcon = if (useMiuixIcons) MiuixIcons.Useful.Settings else Icons.Outlined.Tune, onClick = { navController.navigate("preferences") } ) SettingsActionRow( title = stringResource(id = R.string.check_update), - leadingIcon = Icons.Outlined.Update, + leadingIcon = if (useMiuixIcons) MiuixIcons.Useful.Update else Icons.Outlined.Update, showDivider = false, onClick = { mainViewModel.checkApkUpdateManually(context) { message -> @@ -211,17 +230,17 @@ fun Settings( ) { SettingsActionRow( title = stringResource(id = R.string.license), - leadingIcon = Icons.AutoMirrored.Outlined.Article, + leadingIcon = if (useMiuixIcons) MiuixIcons.Useful.Info else Icons.AutoMirrored.Outlined.Article, onClick = { navController.navigate("settings__license") } ) SettingsActionRow( title = stringResource(id = R.string.contributors), - leadingIcon = Icons.Outlined.PeopleOutline, + leadingIcon = if (useMiuixIcons) MiuixIcons.Useful.Personal else Icons.Outlined.PeopleOutline, onClick = { navController.navigate("settings__contributors") } ) SettingsActionRow( title = stringResource(id = R.string.mine_tv_feedback), - leadingIcon = Icons.Outlined.Feedback, + leadingIcon = if (useMiuixIcons) MiuixIcons.Useful.Edit else Icons.Outlined.Feedback, onClick = { runCatching { context.startActivity( @@ -237,13 +256,12 @@ fun Settings( ) SettingsActionRow( title = stringResource(id = R.string.update_intro), - leadingIcon = Icons.AutoMirrored.Outlined.Article, + leadingIcon = if (useMiuixIcons) MiuixIcons.Useful.Info else Icons.AutoMirrored.Outlined.Article, onClick = { isUpdateLogDialogShown = true } ) SettingsActionRow( title = stringResource(id = R.string.setting_clear), - subtitle = "清除登录状态、课表和本地数据", - leadingIcon = Icons.Outlined.ClearAll, + leadingIcon = if (useMiuixIcons) MiuixIcons.Useful.Delete else Icons.Outlined.ClearAll, destructive = true, showDivider = false, onClick = { isClearDataDialogShown = true } @@ -279,8 +297,18 @@ fun Settings( } if (isUpdateLogDialogShown) { + val dialogShape = SmoothRoundedCornerShape(28.dp) AlertDialog( + modifier = Modifier.appLiquidGlassSurface( + shape = dialogShape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainerHigh, + level = LiquidGlassSurfaceLevel.Floating, + backdropSamplingEnabled = false + ), onDismissRequest = { isUpdateLogDialogShown = false }, + shape = dialogShape, + containerColor = Color.Transparent, + tonalElevation = 0.dp, title = { Text(stringResource(id = R.string.update_intro)) }, text = { Text( diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/Setup.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/Setup.kt index bb3c0df2..4d613346 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/Setup.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/Setup.kt @@ -2,7 +2,6 @@ package com.ahu.ahutong.ui.screen import androidx.activity.compose.BackHandler import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -11,6 +10,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import com.ahu.ahutong.data.dao.AHUCache +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground import com.ahu.ahutong.ui.screen.setup.Info import com.ahu.ahutong.ui.screen.setup.Splash import com.ahu.ahutong.ui.state.AboutViewModel @@ -42,7 +42,7 @@ fun Setup( startDestination = "splash", modifier = Modifier .fillMaxSize() - .background(96.n1 withNight 10.n1) + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1) ) { animatedComposable("splash") { Splash() diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/Splash.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/Splash.kt index 8a045f85..b9b81ba0 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/Splash.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/Splash.kt @@ -1,7 +1,9 @@ package com.ahu.ahutong.ui.screen import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -20,14 +22,19 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.navigation.NavController import androidx.hilt.navigation.compose.hiltViewModel -import com.ahu.ahutong.data.dao.AHUCache +import com.ahu.ahutong.data.dao.AHUCache +import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.components.AppCircularProgressIndicator import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.SplashViewModel import com.ahu.ahutong.ui.state.BootstrapTrainingOnboardingState import com.ahu.ahutong.ui.state.TelemetryOnboardingState +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.withNight @@ -69,7 +76,14 @@ fun Splash( bootstrapTrainingState is BootstrapTrainingOnboardingState.Ready val requiresAcceptance = !agreementAccepted || !privacyAccepted || !businessAccepted || telemetryChoice == null || bootstrapTrainingChoice == null - if (onboardingReady && requiresAcceptance) { + if (!onboardingReady || !requiresAcceptance) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + AppCircularProgressIndicator() + } + } else if (requiresAcceptance) { UnifiedPrivacyPolicyDialog( onAgree = { AHUCache.setAgreementAccepted() @@ -147,7 +161,14 @@ private fun OnboardingDialogTemplate( onDismissRequest: () -> Unit = {}, buttonWidth: androidx.compose.ui.unit.Dp = 88.dp ) { + val dialogShape = SmoothRoundedCornerShape(32.dp) AlertDialog( + modifier = Modifier.appLiquidGlassSurface( + shape = dialogShape, + fallbackColor = 100.n1 withNight 20.n1, + level = LiquidGlassSurfaceLevel.Floating, + backdropSamplingEnabled = false + ), onDismissRequest = onDismissRequest, title = { Text( @@ -169,7 +190,7 @@ private fun OnboardingDialogTemplate( ) } }, - shape = SmoothRoundedCornerShape(32.dp), + shape = dialogShape, confirmButton = { FilledTonalButton( onClick = onConfirm, @@ -196,6 +217,6 @@ private fun OnboardingDialogTemplate( Text(dismissText) } }, - containerColor = 100.n1 withNight 20.n1 + containerColor = Color.Transparent ) } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/BathroomDeposit.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/BathroomDeposit.kt index 4e2e3106..88a4aa10 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/BathroomDeposit.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/BathroomDeposit.kt @@ -1,542 +1,334 @@ -package com.ahu.ahutong.ui.screen.main - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.LocalIndication -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -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.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel -import com.ahu.ahutong.data.crawler.PayState -import com.ahu.ahutong.data.dao.AHUCache -import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape -import com.ahu.ahutong.ui.state.BathroomDepositViewModel -import com.kyant.monet.a1 -import com.kyant.monet.n1 -import com.kyant.monet.withNight -import com.ahu.ahutong.personalization.ui.rememberBehaviorActionReporter +package com.ahu.ahutong.ui.screen.main + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ahu.ahutong.data.crawler.PayState +import com.ahu.ahutong.data.dao.AHUCache import com.ahu.ahutong.personalization.action.AppActionId -import kotlinx.coroutines.delay - -@OptIn(ExperimentalMaterial3Api::class) -@Composable +import com.ahu.ahutong.personalization.ui.rememberBehaviorActionReporter +import com.ahu.ahutong.ui.component.SecurePaymentPasswordDialog +import com.ahu.ahutong.ui.components.AppButton +import com.ahu.ahutong.ui.components.AppButtonVariant +import com.ahu.ahutong.ui.components.AppCircularProgressIndicator +import com.ahu.ahutong.ui.components.AppComponentTokens +import com.ahu.ahutong.ui.components.AppScrollablePageLayout +import com.ahu.ahutong.ui.components.AppSelectField +import com.ahu.ahutong.ui.components.AppSelectOption +import com.ahu.ahutong.ui.components.AppTextField +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.state.BathroomDepositViewModel +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel +import com.kyant.monet.n1 +import com.kyant.monet.withNight +import kotlinx.coroutines.delay + +@Composable fun BathroomDeposit( - - viewmodel: BathroomDepositViewModel = viewModel() - + onBack: () -> Unit, + viewmodel: BathroomDepositViewModel = viewModel() ) { val behaviorReporter = rememberBehaviorActionReporter() - val payState = viewmodel.payState.collectAsState() - LaunchedEffect(payState.value) { - when (payState.value) { - is PayState.Succeeded, is PayState.Failed -> { - delay(1000) - viewmodel.resetPaymentState() - } - - else -> { - - } - } - - } - val options = listOf("竹园/龙河", "桔园/蕙园") - var expanded by remember { mutableStateOf(false) } - var bathroom by remember { mutableStateOf(options[0]) } - - var amount by remember { mutableStateOf("") } - var tel by remember { mutableStateOf("") } - - var hasFocus by remember { mutableStateOf(false) } - val focusManager = LocalFocusManager.current - - val info = viewmodel.info.collectAsState() - - var lastTel by remember { mutableStateOf(null) } - - LaunchedEffect(Unit) { - lastTel = AHUCache.getPhone() - } - - val textFieldColors = TextFieldDefaults.colors( - unfocusedContainerColor = Color.Transparent, - focusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ) - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .systemBarsPadding() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - focusManager.clearFocus() - }, - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - Text( - text = "浴室缴费", - modifier = Modifier.padding(24.dp, 32.dp), - style = MaterialTheme.typography.headlineMedium - ) - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(100.n1 withNight 20.n1) - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - ) { - Text( - text = "选择浴室", - style = MaterialTheme.typography.titleMedium - ) - - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded } - ) { - TextField( - value = bathroom, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - onValueChange = {}, - readOnly = true, - modifier = Modifier - .menuAnchor() - .width(150.dp), - colors = textFieldColors, - textStyle = TextStyle( - textAlign = TextAlign.End, - fontSize = 16.sp, - color = 10.n1 withNight 90.n1 - ), - singleLine = true, - ) - - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier.background(99.n1 withNight 10.n1), - ) { - options.forEach { selectionOption -> - DropdownMenuItem( - text = { Text(selectionOption, color = 10.n1 withNight 90.n1) }, - onClick = { - bathroom = selectionOption - expanded = false - } - ) - } - } - } - } - Row( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = "手机号", style = MaterialTheme.typography.titleMedium) - TextField( - value = tel, - onValueChange = { value -> - tel = value - }, - modifier = Modifier - .width(150.dp) - .onFocusChanged { - if (!it.isFocused && hasFocus && !tel.isEmpty()) { - viewmodel.getBathroomInfo(bathroom, tel) - } - hasFocus = it.isFocused - }, - colors = textFieldColors, - textStyle = TextStyle( - textAlign = TextAlign.Center, - fontSize = 16.sp, - color = 10.n1 withNight 90.n1 - ), - - singleLine = true, - ) - } - - - lastTel?.let { - Row(horizontalArrangement = Arrangement.End) { - AnimatedVisibility( - visible = (lastTel != null && !hasFocus), - enter = fadeIn() + slideInVertically(), - exit = fadeOut() + slideOutVertically() - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - Text( - text = "上次充值:$it", - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .background(90.a1 withNight 30.n1) - .padding(8.dp) - .clickable { - tel = it - viewmodel.getBathroomInfo(bathroom, tel) - lastTel = null - } - - - ) - } - } - } - } - - - Row( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = "信息", style = MaterialTheme.typography.titleMedium) - - val displayText = info.value?.let { it -> - when { - it.data.map == null -> it.data.message ?: "未知错误" - it.data.map!!.showData != null -> { - val showData = it.data.map!!.showData!! - "${showData.phone}\n现金金额:${showData.cashAmount}元\n赠送金额:${showData.giftAmount}元" - } - - it.data.map!!.data?.message != null -> it.data.map!!.data!!.message!! - else -> "未知错误" - } - } ?: "" - - Text(text = displayText) - } - - - } - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(100.n1 withNight 20.n1), - ) { - - Text( - text = "缴费金额", - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.titleMedium - - ) - TextField( - value = amount, - onValueChange = { newText -> - if (newText.isEmpty()) { - amount = newText - return@TextField - } - - val regex = Regex("^\\d*\\.?\\d{0,2}$") - if (regex.matches(newText)) { - amount = newText - } - }, - modifier = Modifier.fillMaxWidth(), - colors = textFieldColors, - placeholder = { Text("请输入金额", color = 30.n1 withNight 70.n1) }, - textStyle = TextStyle(fontSize = 16.sp, color = 10.n1 withNight 90.n1), - - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Decimal, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { focusManager.clearFocus() } - ), - singleLine = true - ) - - - } - - var showDialog by remember { mutableStateOf(false) } - var password by remember { mutableStateOf("") } - var errorMsg by remember { mutableStateOf(null) } - - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - Box( - modifier = Modifier - .navigationBarsPadding() - .padding(16.dp) - .clip(SmoothRoundedCornerShape(32.dp)) - .background( - animateColorAsState( - targetValue = when (payState.value) { - is PayState.Idle -> 90.a1 withNight 85.a1 - is PayState.InProgress -> 70.a1 withNight 60.a1 - is PayState.Failed -> Color.Red - is PayState.Succeeded -> 70.a1 withNight 60.a1 - } - ).value - ) - .animateContentSize(spring(stiffness = Spring.StiffnessLow)) - ) { - when (val state = payState.value) { - PayState.Idle -> { - CompositionLocalProvider(LocalIndication provides ripple(color = 0.n1)) { - Text( - text = "确认", - modifier = Modifier - .clickable( - role = Role.Button, - onClick = { - if (!amount.isEmpty() && info.value != null) { - showDialog = true - } else { - - } - } - ) - .padding(24.dp, 16.dp), - color = 0.n1, - style = MaterialTheme.typography.titleMedium - ) - } - } - - PayState.InProgress -> { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalArrangement = Arrangement.spacedBy(24.dp), - verticalAlignment = Alignment.CenterVertically - ) { - CircularProgressIndicator( - modifier = Modifier.size(56.dp), - color = 100.n1, - strokeWidth = 6.dp - ) - Text( - text = "支付中", - modifier = Modifier.padding(4.dp), - color = 100.n1, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.headlineSmall - ) - } - } - - is PayState.Failed -> { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalArrangement = Arrangement.spacedBy(24.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(56.dp), - tint = 100.n1 - ) - Text( - text = "支付失败! ${state.message}", - modifier = Modifier.padding(4.dp), - color = 100.n1, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.headlineSmall - ) - } - } - - is PayState.Succeeded -> { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalArrangement = Arrangement.spacedBy(24.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(56.dp), - tint = 100.n1 - ) - Text( - text = "支付成功! 订单号:${state.message}", - modifier = Modifier - .padding(4.dp) - .clickable { - - }, - color = 100.n1, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.headlineSmall - ) - } - } - - } - } - - - if (showDialog) { - AlertDialog( - containerColor = 100.n1 withNight 20.n1, - titleContentColor = 10.n1 withNight 90.n1, - textContentColor = 10.n1 withNight 90.n1, - onDismissRequest = { showDialog = false }, - title = { Text("请输入校园卡密码") }, - text = { - Column { - OutlinedTextField( - value = password, - onValueChange = { input -> - if (input.length <= 6 && input.all { it.isDigit() }) { - password = input - errorMsg = null - } - }, - label = { Text("密码 (6位数字)", color = 40.n1 withNight 60.n1) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), - visualTransformation = PasswordVisualTransformation(), - isError = errorMsg != null, - colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = 10.n1 withNight 90.n1, - unfocusedTextColor = 10.n1 withNight 90.n1, - focusedBorderColor = 20.n1 withNight 80.n1 - ) - ) - if (errorMsg != null) { - Text( - text = errorMsg!!, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall - ) - } - } - }, - confirmButton = { - TextButton(onClick = { - if (password.length == 6) { - showDialog = false - behaviorReporter.organic(AppActionId.CONFIRM_BATHROOM_PAYMENT) - viewmodel.pay( - bathroom = bathroom, - amount = amount, - password = password - ) - } else { - errorMsg = "密码必须是6位数字" - } - }) { - Text("确认", color = 10.n1 withNight 90.n1) - } - }, - dismissButton = { - TextButton(onClick = { - showDialog = false - password = "" - errorMsg = null - }) { - Text("取消", color = 10.n1 withNight 90.n1) - } - } - ) - - - } - } - } -} - + val payState by viewmodel.payState.collectAsState() + val info by viewmodel.info.collectAsState() + val isQuerying by viewmodel.isQuerying.collectAsState() + val focusManager = LocalFocusManager.current + + val bathrooms = remember { listOf("竹园/龙河", "桔园/蕙园") } + val bathroomOptions = remember(bathrooms) { + bathrooms.map { AppSelectOption(it, it) } + } + var bathroom by rememberSaveable { mutableStateOf(bathrooms.first()) } + var amount by rememberSaveable { mutableStateOf("") } + var phone by rememberSaveable { mutableStateOf("") } + var phoneHasFocus by rememberSaveable { mutableStateOf(false) } + var previousPhone by rememberSaveable { mutableStateOf(null) } + var showPasswordDialog by rememberSaveable { mutableStateOf(false) } + var password by rememberSaveable { mutableStateOf("") } + var passwordError by rememberSaveable { mutableStateOf(null) } + + LaunchedEffect(Unit) { + previousPhone = AHUCache.getPhone()?.takeIf(String::isNotBlank) + } + LaunchedEffect(bathroom, phone) { + if (phone.length == 11) { + delay(250) + viewmodel.getBathroomInfo(bathroom, phone) + } + } + LaunchedEffect(payState) { + if (payState is PayState.Succeeded || payState is PayState.Failed) { + delay(PAYMENT_RESULT_DISPLAY_DURATION_MS) + viewmodel.resetPaymentState() + } + } + + val accountSummary = info?.let { response -> + when { + response.data.map == null -> response.data.message ?: "未查询到浴室账户" + response.data.map!!.showData != null -> response.data.map!!.showData!!.let { data -> + "${data.phone} · 现金 ${data.cashAmount} 元 · 赠送 ${data.giftAmount} 元" + } + response.data.map!!.data?.message != null -> response.data.map!!.data!!.message!! + else -> "未查询到浴室账户" + } + } + val accountData = info?.data?.map?.data + val balanceData = info?.data?.map?.showData + val canSubmit = amount.toDoubleOrNull()?.let { it > 0.0 } == true && accountData != null && + payState !is PayState.InProgress + + AppScrollablePageLayout( + title = "浴室缴费", + onBack = onBack, + modifier = Modifier + .fillMaxSize() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), + bottomPadding = 48.dp + ) { + AppSelectField( + label = "浴室", + selected = bathroom, + options = bathroomOptions, + onSelected = { selected -> + bathroom = selected + viewmodel.clearBathroomInfo() + }, + modifier = Modifier.padding(horizontal = 16.dp), + miuixInsideMargin = androidx.compose.foundation.layout.PaddingValues( + start = 12.dp, + top = 16.dp, + end = 20.dp, + bottom = 16.dp + ), + miuixStandalone = true, + liquidLabelWeight = 0.85f, + liquidValueWeight = 1.15f + ) + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + AppTextField( + value = phone, + onValueChange = { input -> + val nextPhone = input.filter(Char::isDigit).take(11) + if (nextPhone != phone) { + phone = nextPhone + viewmodel.clearBathroomInfo() + } + }, + label = "手机号", + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> phoneHasFocus = focusState.isFocused }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Phone, + imeAction = ImeAction.Search + ), + keyboardActions = KeyboardActions( + onSearch = { + focusManager.clearFocus() + viewmodel.getBathroomInfo(bathroom, phone) + } + ) + ) + AppButton( + onClick = { + focusManager.clearFocus() + viewmodel.getBathroomInfo(bathroom, phone) + }, + modifier = Modifier.fillMaxWidth(), + enabled = phone.length == 11 && !isQuerying + ) { + Text("查询") + } + + AnimatedVisibility(visible = previousPhone != null && !phoneHasFocus) { + AppButton( + onClick = { + val cachedPhone = previousPhone ?: return@AppButton + phone = cachedPhone + previousPhone = null + viewmodel.clearBathroomInfo() + }, + modifier = Modifier.fillMaxWidth(), + variant = AppButtonVariant.Secondary + ) { + Text("使用上次充值手机号 · ${previousPhone.orEmpty()}") + } + } + } + + AnimatedVisibility(visible = isQuerying || info != null) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .appLiquidGlassSurface( + shape = AppComponentTokens.CardShape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainerHigh, + level = LiquidGlassSurfaceLevel.Control + ) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("浴室账户", style = MaterialTheme.typography.titleMedium) + if (isQuerying) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AppCircularProgressIndicator(size = 22.dp, strokeWidth = 3.dp) + Text("正在查询账户与余额", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else if (balanceData != null) { + Text( + text = accountData?.name?.takeIf(String::isNotBlank) + ?: accountData?.identifier?.takeIf(String::isNotBlank) + ?: balanceData.phone, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "现金 ${balanceData.cashAmount} 元 · 赠送 ${balanceData.giftAmount} 元", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium + ) + } else { + Text( + text = accountSummary ?: "未查询到浴室账户", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "缴费金额", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + AppTextField( + value = amount, + onValueChange = { input -> + if (input.isEmpty() || Regex("^\\d*\\.?\\d{0,2}$").matches(input)) { + amount = input + } + }, + label = "金额(元)", + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) + } + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + when (val state = payState) { + PayState.Idle -> Unit + PayState.InProgress -> Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + AppCircularProgressIndicator(size = 24.dp, strokeWidth = 3.dp) + Text(" 正在提交缴费", style = MaterialTheme.typography.bodyLarge) + } + is PayState.Failed -> Text( + text = "缴费失败:${state.message}", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium + ) + is PayState.Succeeded -> Text( + text = "缴费成功,订单号:${state.message}", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyMedium + ) + } + + AppButton( + onClick = { showPasswordDialog = true }, + modifier = Modifier.fillMaxWidth(), + enabled = canSubmit + ) { + Text(if (payState is PayState.InProgress) "正在支付" else "确认缴费") + } + } + } + + if (showPasswordDialog) { + SecurePaymentPasswordDialog( + password = password, + onPasswordChange = { + password = it + passwordError = null + }, + title = "请输入校园卡密码", + errorMessage = passwordError, + onDismissRequest = { + showPasswordDialog = false + password = "" + passwordError = null + }, + onConfirm = { confirmedPassword -> + if (confirmedPassword.length == 6) { + showPasswordDialog = false + behaviorReporter.organic(AppActionId.CONFIRM_BATHROOM_PAYMENT) + viewmodel.pay( + bathroom = bathroom, + amount = amount, + password = confirmedPassword + ) + } else { + passwordError = "密码必须是 6 位数字" + } + } + ) + } +} + +private const val PAYMENT_RESULT_DISPLAY_DURATION_MS = 3_000L diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/CardBalanceDeposit.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/CardBalanceDeposit.kt index 84f5843f..d9add871 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/CardBalanceDeposit.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/CardBalanceDeposit.kt @@ -7,42 +7,20 @@ import android.content.Intent import android.net.Uri import android.widget.Toast -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.foundation.LocalIndication -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.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider +import com.ahu.ahutong.ui.components.AppCircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -51,30 +29,42 @@ 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.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import com.ahu.ahutong.data.dao.AHUCache +import com.ahu.ahutong.data.model.AppUiTheme +import com.ahu.ahutong.data.model.CardRechargeBank import com.ahu.ahutong.data.mock.MockScenarioController -import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.components.AppButton +import com.ahu.ahutong.ui.components.AppButtonVariant +import com.ahu.ahutong.ui.components.AppScrollablePageLayout +import com.ahu.ahutong.ui.components.AppComponentTokens +import com.ahu.ahutong.ui.components.AppDialogSurface +import com.ahu.ahutong.ui.components.AppSelectField +import com.ahu.ahutong.ui.components.AppSelectOption +import com.ahu.ahutong.ui.components.AppTextField +import com.ahu.ahutong.ui.components.AppToggle +import com.ahu.ahutong.ui.components.LocalAppUiTheme +import com.ahu.ahutong.ui.components.SettingsChoice +import com.ahu.ahutong.ui.components.SettingsSelectRow import com.ahu.ahutong.ui.state.CardAccountState import com.ahu.ahutong.ui.state.CardBalanceDepositViewModel import com.ahu.ahutong.ui.state.PaymentState -import com.kyant.monet.a1 -import com.kyant.monet.n1 +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel +import com.kyant.monet.n1 import com.kyant.monet.withNight import com.ahu.ahutong.personalization.ui.rememberBehaviorActionReporter import com.ahu.ahutong.personalization.action.AppActionId +import kotlinx.coroutines.delay private const val ALIPAY_CAMPUS_CARD_SCHEME = "alipays://platformapi/startapp?appId=2019090967125695&page=pages%2Findex%2Findex&chInfo=ch_share__chsub_CopyLink" @@ -96,8 +86,9 @@ fun CardBalanceDeposit( val paymentState by viewModel.paymentState.collectAsState() - var showConfirmDialog by remember { mutableStateOf(false) } - var showCmbPreferenceDialog by remember { mutableStateOf(false) } + var showAlipayConfirmDialog by remember { mutableStateOf(false) } + var copyCampusCardInfo by remember { mutableStateOf(false) } + var selectedRechargeBank by remember { mutableStateOf(AHUCache.getCardRechargeBank()) } val context = LocalContext.current val focusManager = LocalFocusManager.current @@ -105,6 +96,34 @@ fun CardBalanceDeposit( val currentUser = remember { AHUCache.getCurrentUser() } val campusCardUserName = currentUser?.name.orEmpty() val campusCardStudentId = currentUser?.xh.orEmpty() + fun selectRechargeBank(bank: CardRechargeBank) { + if (paymentState == PaymentState.Loading) return + selectedRechargeBank = bank + AHUCache.setCardRechargeBank(bank) + if (bank == CardRechargeBank.ALIPAY) copyCampusCardInfo = false + viewModel.resetPaymentState() + } + + fun submitRecharge() { + when (selectedRechargeBank) { + CardRechargeBank.ALIPAY -> showAlipayConfirmDialog = true + CardRechargeBank.CHINA_MERCHANTS_BANK -> { + behaviorReporter.organic(AppActionId.SUBMIT_CMB_CARD_RECHARGE) + viewModel.charge( + value = amount, + bank = CardRechargeBank.CHINA_MERCHANTS_BANK + ) + } + CardRechargeBank.AGRICULTURAL_BANK -> { + behaviorReporter.organic(AppActionId.SUBMIT_CARD_RECHARGE) + viewModel.charge( + value = amount, + bank = CardRechargeBank.AGRICULTURAL_BANK + ) + } + null -> Unit + } + } LaunchedEffect(Unit) { viewModel.load() @@ -115,28 +134,82 @@ fun CardBalanceDeposit( viewModel.load() } } + + LaunchedEffect(paymentState, selectedRechargeBank) { + if (paymentState is PaymentState.Success) { + delay(1_000L) + viewModel.load() + delay(PAYMENT_RESULT_DISPLAY_DURATION_MS - 1_000L) + viewModel.resetPaymentState() + } else if (paymentState is PaymentState.Error) { + delay(PAYMENT_RESULT_DISPLAY_DURATION_MS) + viewModel.resetPaymentState() + } + } + val canConfirm = paymentState == PaymentState.Idle && when (selectedRechargeBank) { + CardRechargeBank.ALIPAY -> true + CardRechargeBank.CHINA_MERCHANTS_BANK, + CardRechargeBank.AGRICULTURAL_BANK -> { + amount.toDoubleOrNull()?.let { it > 0.0 } == true && + accountState is CardAccountState.Ready + } + null -> false + } - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .systemBarsPadding(), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - - Text( - text = "校园卡充值", - modifier = Modifier.padding(24.dp, 32.dp), - style = MaterialTheme.typography.headlineMedium - ) - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(16.dp)) - .background(100.n1 withNight 20.n1) + AppScrollablePageLayout( + title = "校园卡充值", + onBack = { navController.popBackStack() }, + modifier = Modifier + .fillMaxSize() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), + bottomPadding = 48.dp + ) { + if (LocalAppUiTheme.current != AppUiTheme.MATERIAL) { + AppSelectField( + label = "充值方式", + selected = selectedRechargeBank, + options = CardRechargeBank.entries.map { method -> + AppSelectOption(method, method.displayName) + }, + onSelected = ::selectRechargeBank, + modifier = Modifier.padding(horizontal = 16.dp), + enabled = paymentState != PaymentState.Loading, + valueTextAlign = TextAlign.End, + miuixInsideMargin = androidx.compose.foundation.layout.PaddingValues( + start = 12.dp, + top = 16.dp, + end = 16.dp, + bottom = 16.dp + ), + miuixStandalone = true + ) + } + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .appLiquidGlassSurface( + shape = AppComponentTokens.CardShape, + fallbackColor = 100.n1 withNight 20.n1, + level = LiquidGlassSurfaceLevel.Panel + ) ) { + if (LocalAppUiTheme.current == AppUiTheme.MATERIAL) { + SettingsSelectRow( + title = "充值方式", + selected = selectedRechargeBank, + choices = CardRechargeBank.entries.map { method -> + SettingsChoice(method, method.displayName) + }, + onSelected = { method -> method?.let(::selectRechargeBank) }, + showDivider = false + ) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.outlineVariant + ) + } Row( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier @@ -150,8 +223,8 @@ fun CardBalanceDeposit( when (val state = accountState) { CardAccountState.Loading -> { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), + AppCircularProgressIndicator( + size = 18.dp, strokeWidth = 2.dp, color = 30.n1 withNight 70.n1 ) @@ -168,7 +241,7 @@ fun CardBalanceDeposit( is CardAccountState.Error -> { Text( text = "加载失败", - color = Color.Red + color = MaterialTheme.colorScheme.error ) } } @@ -190,229 +263,136 @@ fun CardBalanceDeposit( } - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(16.dp)) - .background(100.n1 withNight 20.n1), - ) { - - Text( - text = "充值金额", - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.titleMedium - - ) - - TextField( - value = amount, - onValueChange = { newText -> - if (newText.isEmpty()) { - amount = newText - return@TextField - } - - val regex = Regex("^\\d*\\.?\\d{0,2}$") - if (regex.matches(newText)) { - amount = newText - } - }, - modifier = Modifier.fillMaxWidth(), - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - placeholder = { Text("请输入金额", color = 30.n1 withNight 70.n1) }, - textStyle = TextStyle(fontSize = 16.sp, color = 10.n1 withNight 90.n1), - - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Decimal, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { focusManager.clearFocus() } - ), - singleLine = true - ) - } - + if (selectedRechargeBank != CardRechargeBank.ALIPAY) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "充值金额", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + AppTextField( + value = amount, + onValueChange = { newText -> + if (newText.isEmpty()) { + amount = newText + return@AppTextField + } - Row( + val regex = Regex("^\\d*\\.?\\d{0,2}$") + if (regex.matches(newText)) { + amount = newText + } + }, + label = "金额(元)", + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { focusManager.clearFocus() } + ) + ) + } + } + + Column( modifier = Modifier .fillMaxWidth() - .navigationBarsPadding() - .padding(start = 24.dp, top = 16.dp, end = 16.dp, bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Text( - text = "招商银行充值点这里", - modifier = Modifier - .clickable { showCmbPreferenceDialog = true } - .padding(horizontal = 8.dp, vertical = 16.dp), - color = 30.n1 withNight 70.n1, - style = MaterialTheme.typography.bodyMedium - ) - Spacer(modifier = Modifier.weight(1f)) - Box( - modifier = Modifier - .clip(SmoothRoundedCornerShape(32.dp)) - .background( - animateColorAsState( - targetValue = when (paymentState) { - PaymentState.Idle -> 90.a1 withNight 85.a1 - PaymentState.Loading -> 70.a1 withNight 60.a1 - is PaymentState.Error -> Color.Red - is PaymentState.Success -> 70.a1 withNight 60.a1 - } - ).value - ) - .animateContentSize(spring(stiffness = Spring.StiffnessLow)) - ) { - when (val state = paymentState) { - PaymentState.Idle -> { - CompositionLocalProvider(LocalIndication provides ripple(color = 0.n1)) { - Text( - text = "确认", - modifier = Modifier - .clickable( - role = Role.Button, - onClick = { - if (amount.isNotEmpty()) { - showConfirmDialog = true // 点击显示弹窗 - } - } - ) - .padding(24.dp, 16.dp), - color = 0.n1, - style = MaterialTheme.typography.titleMedium - ) - } - } - - - PaymentState.Loading -> { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalArrangement = Arrangement.spacedBy(24.dp), - verticalAlignment = Alignment.CenterVertically - ) { - CircularProgressIndicator( - modifier = Modifier.size(56.dp), - color = 100.n1, - strokeWidth = 6.dp - ) - Text( - text = "支付中", - modifier = Modifier.padding(4.dp), - color = 100.n1, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.headlineSmall - ) - } - } - - is PaymentState.Error -> { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalArrangement = Arrangement.spacedBy(24.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(56.dp), - tint = 100.n1 - ) - Text( - text = "支付失败!错误信息:${state.message}", - modifier = Modifier.padding(4.dp), - color = 100.n1, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.headlineSmall - ) - } - } - - is PaymentState.Success -> { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalArrangement = Arrangement.spacedBy(24.dp), - verticalAlignment = Alignment.CenterVertically - ) { - - - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(56.dp), - tint = 100.n1 - ) - Text( - text = "支付成功!订单号:${state.orderId}", - modifier = Modifier - .padding(4.dp) - .clickable { - viewModel.resetPaymentState() - }, - color = 100.n1, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.headlineSmall - ) - } - } - - } - } - } - - - if (showConfirmDialog) { - AlertDialog( - - containerColor = 100.n1 withNight 20.n1, - titleContentColor = 10.n1 withNight 90.n1, - onDismissRequest = { showConfirmDialog = false }, - title = { Text("确认支付") }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - "请选择支付方式。银行卡支付将从绑定的银行卡扣除¥$amount 元;支付宝支付会复制本地校园卡信息并跳转支付宝校园卡小程序。", - color = 40.n1 withNight 60.n1 - ) - Text( - text = "姓名:${campusCardUserName.ifBlank { "未获取到" }}\n学号:${campusCardStudentId.ifBlank { "未获取到" }}", - color = 10.n1 withNight 90.n1, - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = if (campusCardUserName.isBlank() || campusCardStudentId.isBlank()) { - "本地姓名或学号缺失,跳转后请在支付宝中手动填写。" - } else { - "点击支付宝支付后将复制以上信息,跳转后可在支付宝中粘贴填写。" - }, - color = 40.n1 withNight 60.n1, - style = MaterialTheme.typography.bodySmall - ) + if (selectedRechargeBank == CardRechargeBank.ALIPAY) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "复制校园卡信息", + style = MaterialTheme.typography.bodyMedium + ) + AppToggle( + checked = copyCampusCardInfo, + onCheckedChange = { copyCampusCardInfo = it }, + contentDescription = "复制校园卡信息" + ) + } + } + when (val state = paymentState) { + PaymentState.Idle -> Unit + PaymentState.Loading -> Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AppCircularProgressIndicator(size = 24.dp, strokeWidth = 3.dp) + Text("正在提交充值", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + is PaymentState.Error -> Text( + text = "充值失败:${state.message}", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium + ) + is PaymentState.Success -> Text( + text = if (selectedRechargeBank == CardRechargeBank.CHINA_MERCHANTS_BANK) { + "充值成功,请刷卡将过渡余额转入校园卡" + } else { + "充值成功,订单号:${state.orderId}" + }, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyMedium + ) + } + AppButton( + onClick = ::submitRecharge, + modifier = Modifier.fillMaxWidth(), + enabled = canConfirm, + variant = AppButtonVariant.Primary + ) { + Text( + when { + paymentState == PaymentState.Loading -> "正在充值" + selectedRechargeBank == CardRechargeBank.ALIPAY -> "前往支付宝" + else -> "确认充值" } - }, - confirmButton = { - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Text( - text = "支付宝支付", - modifier = Modifier - .clickable { - behaviorReporter.organic(AppActionId.SUBMIT_CARD_RECHARGE) + ) + } + } + + if (showAlipayConfirmDialog) { + AppDialogSurface( + onDismissRequest = { showAlipayConfirmDialog = false } + ) { + Column( + modifier = Modifier.padding(24.dp) + ) { + Text("前往支付宝充值", style = MaterialTheme.typography.headlineSmall) + Text( + "确认打开支付宝校园卡充值页面?", + modifier = Modifier.padding(top = 12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End) + ) { + AppButton( + onClick = { showAlipayConfirmDialog = false }, + variant = AppButtonVariant.Secondary + ) { Text("取消") } + AppButton( + onClick = { + behaviorReporter.organic(AppActionId.SUBMIT_CARD_RECHARGE) + if (copyCampusCardInfo) { val identityState = copyCampusCardIdentity( context = context, name = campusCardUserName, @@ -424,94 +404,27 @@ fun CardBalanceDeposit( CampusCardIdentityCopyState.Empty -> "本地未找到姓名和学号,请在支付宝中手动填写" } Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - openAlipayCampusCard(context) - showConfirmDialog = false } - .padding(8.dp), - color = 10.n1 withNight 90.n1 - ) - Text( - text = "银行卡支付", - modifier = Modifier - .clickable { - if (accountState is CardAccountState.Ready) { - behaviorReporter.organic(AppActionId.SUBMIT_CARD_RECHARGE) - viewModel.charge(amount) - showConfirmDialog = false - } else { - Toast.makeText(context, "校园卡账户仍在加载,请稍后重试", Toast.LENGTH_SHORT).show() - } - } - .padding(8.dp), - color = 10.n1 withNight 90.n1 - ) - } - }, - dismissButton = { - Text( - text = "取消", - modifier = Modifier - .clickable { showConfirmDialog = false } - .padding(8.dp), - color = 10.n1 withNight 90.n1 - ) - } - ) - } - - if (showCmbPreferenceDialog) { - AlertDialog( - containerColor = 100.n1 withNight 20.n1, - titleContentColor = 10.n1 withNight 90.n1, - textContentColor = 40.n1 withNight 60.n1, - onDismissRequest = { showCmbPreferenceDialog = false }, - title = { Text("使用招商银行充值") }, - text = { Text("是否以后都默认使用招商银行充值?") }, - confirmButton = { - Text( - text = "以后都用", - modifier = Modifier - .clickable { - val oldPreference = AHUCache.isCmbCardRechargePreferred() - AHUCache.setCmbCardRechargePreferred(true) - if (!oldPreference && AHUCache.isCmbCardRechargePreferred()) { - behaviorReporter.cmbRechargePreferenceChanged(false, true) - } - showCmbPreferenceDialog = false - navController.navigate("cmb_card_recharge") + openAlipayCampusCard(context) + showAlipayConfirmDialog = false } - .padding(8.dp), - color = 10.n1 withNight 90.n1 - ) - }, - dismissButton = { - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Text( - text = "取消", - modifier = Modifier - .clickable { showCmbPreferenceDialog = false } - .padding(8.dp), - color = 10.n1 withNight 90.n1 - ) - Text( - text = "仅本次", - modifier = Modifier - .clickable { - showCmbPreferenceDialog = false - navController.navigate("cmb_card_recharge") - } - .padding(8.dp), - color = 10.n1 withNight 90.n1 - ) + ) { Text("确认") } } } - ) + } } } } +private val CardRechargeBank.displayName: String + get() = when (this) { + CardRechargeBank.AGRICULTURAL_BANK -> "中国农业银行" + CardRechargeBank.CHINA_MERCHANTS_BANK -> "招商银行" + CardRechargeBank.ALIPAY -> "支付宝" + } + private enum class CampusCardIdentityCopyState { Complete, Partial, @@ -529,9 +442,12 @@ private fun copyCampusCardIdentity( return CampusCardIdentityCopyState.Empty } - val clipText = "姓名:$trimmedName\n学号:$trimmedStudentId" + val clipText = buildList { + if (trimmedName.isNotEmpty()) add("姓名:$trimmedName") + if (trimmedStudentId.isNotEmpty()) add("学号:$trimmedStudentId") + }.joinToString("\n") val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("校园卡身份信息", clipText)) + clipboard.setPrimaryClip(ClipData.newPlainText("校园卡信息", clipText)) return if (trimmedName.isNotEmpty() && trimmedStudentId.isNotEmpty()) { CampusCardIdentityCopyState.Complete @@ -540,6 +456,8 @@ private fun copyCampusCardIdentity( } } +private const val PAYMENT_RESULT_DISPLAY_DURATION_MS = 3_000L + private fun openAlipayCampusCard(context: Context) { val openedAlipay = runCatching { context.startActivity( 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 deleted file mode 100644 index 25759370..00000000 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbCardRecharge.kt +++ /dev/null @@ -1,724 +0,0 @@ -package com.ahu.ahutong.ui.screen.main - -import android.annotation.SuppressLint -import android.content.ActivityNotFoundException -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.os.SystemClock -import android.widget.Toast -import android.webkit.WebChromeClient -import android.webkit.JavascriptInterface -import android.webkit.WebResourceError -import android.webkit.WebResourceRequest -import android.webkit.WebSettings -import android.webkit.WebView -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.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 -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 kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okhttp3.Cookie -import java.net.URI - -internal data class CmbRechargeNormalizedBounds( - val left: Float, - val top: Float, - val width: Float, - val height: Float -) - -private const val CMB_SUBMIT_OBSERVER_SCRIPT = """ -(function(){ - if (window.__ahutongSubmitObserverInstalled) return; - window.__ahutongSubmitObserverInstalled = true; - var lastNotice = 0; - function notify(){ - var now = Date.now(); - if (now - lastNotice < 1000) return; - lastNotice = now; - window.AhuTongBehaviorBridge.onSubmitIntent(); - } - document.addEventListener('submit', notify, true); - document.addEventListener('click', function(event){ - var target = event.target && event.target.closest - ? event.target.closest('button[type="submit"],input[type="submit"]') - : null; - if (target) notify(); - }, true); -})(); -""" - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -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 isRechargeSuccessPage by remember { mutableStateOf(false) } - var successReturnBounds by remember { - mutableStateOf(null) - } - var errorMessage by remember { mutableStateOf(null) } - - BackHandler(onBack = onExit) - - fun reloadEntry() { - progress = 0 - isLoading = true - errorMessage = null - isRechargeSuccessPage = false - successReturnBounds = null - webView?.stopLoading() - loadRequestVersion += 1 - } - - LaunchedEffect(tokenRequestVersion) { - progress = 0 - isLoading = true - errorMessage = null - entryUrl = null - isRechargeSuccessPage = false - successReturnBounds = null - val token = withContext(Dispatchers.IO) { TokenManager.awaitToken() } - if (token.isNullOrBlank()) { - errorMessage = "校园卡登录凭证暂未就绪,请稍后重试" - isLoading = false - return@LaunchedEffect - } - entryUrl = buildCmbRechargeEntryUrl(token) - loadRequestVersion += 1 - } - - DisposableEffect(Unit) { - onDispose { - webView?.stopLoading() - webView?.cmbRechargeState?.boundsLocator?.dispose() - webView?.destroy() - webView = null - } - } - - LaunchedEffect(pageStyleScript) { - webView?.let { currentView -> - applyCmbRechargePageStyle(currentView, currentView.url, pageStyleScript) - currentView.cmbRechargeState?.boundsLocator?.locate(currentView.url) - } - } - - 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(contentPadding) - .background(pageBackgroundColor) - ) { - entryUrl?.let { url -> - val requestVersion = loadRequestVersion - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { viewContext -> - createCmbRechargeWebView( - context = viewContext, - pageBackgroundColor = pageBackgroundColor.toArgb(), - pageStyleScript = { latestPageStyleScript.value }, - onLoadingChanged = { isLoading = it }, - onProgressChanged = { progress = it }, - onSuccessPageChanged = { isSuccessPage -> - isRechargeSuccessPage = isSuccessPage - if (!isSuccessPage) successReturnBounds = null - }, - onSuccessReturnBoundsChanged = { successReturnBounds = it }, - onMainFrameError = { error -> - errorMessage = error - }, - onExternalLink = { externalUrl -> - openExternalLink(context, externalUrl) - }, - onSubmitIntent = { - behaviorReporter.organic(AppActionId.SUBMIT_CMB_CARD_RECHARGE) - } - ).also { created -> - syncYcardCookiesToWebView(created) - created.cmbRechargeState?.requestVersion = requestVersion - created.loadUrl(url) - webView = created - } - }, - update = { currentView -> - currentView.setBackgroundColor(pageBackgroundColor.toArgb()) - if (currentView.cmbRechargeState?.requestVersion != requestVersion) { - syncYcardCookiesToWebView(currentView) - currentView.cmbRechargeState?.requestVersion = requestVersion - currentView.loadUrl(url) - } - webView = currentView - } - ) - } - - if (isRechargeSuccessPage) { - successReturnBounds?.let { bounds -> - CmbRechargeSuccessReturnOverlay( - bounds = bounds, - onClick = { - successReturnBounds = null - latestRechargeSuccessExit.value() - } - ) - } - } - - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center), - 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, - 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 - settings.javaScriptCanOpenWindowsAutomatically = false - settings.allowFileAccess = false - settings.allowContentAccess = false - settings.saveFormData = false - settings.useWideViewPort = true - settings.loadWithOverviewMode = true - settings.cacheMode = WebSettings.LOAD_NO_CACHE - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW - android.webkit.CookieManager.getInstance().setAcceptThirdPartyCookies(this, false) - } - 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) { - onProgressChanged(newProgress) - if (newProgress >= 100) { - onLoadingChanged(false) - } - } - } - - 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? - ): Boolean { - val targetUri = request?.url ?: return false - val scheme = targetUri.scheme?.lowercase().orEmpty() - if (scheme.isBlank()) return false - if (scheme != "http" && scheme != "https") { - onExternalLink(targetUri.toString()) - return true - } - return if (isInternalCmbRechargeUrl(targetUri)) { - false - } else { - onExternalLink(targetUri.toString()) - true - } - } - - override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { - onLoadingChanged(true) - boundsLocator.clear() - updateSuccessPage(url) - super.onPageStarted(view, url, favicon) - } - - override fun onPageFinished(view: WebView?, url: String?) { - onLoadingChanged(false) - 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?, - error: WebResourceError? - ) { - if (request?.isForMainFrame == true) { - onLoadingChanged(false) - boundsLocator.clear() - onSuccessPageChanged(false) - onMainFrameError(error?.description?.toString() ?: "页面加载失败,请稍后重试") - } - super.onReceivedError(view, request, error) - } - } - } -} - -private class CmbBehaviorBridge( - private val webView: WebView, - private val onSubmitIntent: () -> Unit -) { - private var lastAcceptedAtElapsedMs = 0L - - @JavascriptInterface - fun onSubmitIntent() { - webView.post { - val current = webView.url?.let(Uri::parse) - val now = SystemClock.elapsedRealtime() - if (current?.let(::isAuditedCmbSubmitPage) == true && - now - lastAcceptedAtElapsedMs >= NATIVE_SUBMIT_DEBOUNCE_MS - ) { - lastAcceptedAtElapsedMs = now - onSubmitIntent() - } - } - } - - 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") - .authority("ycard.ahu.edu.cn") - .appendPath("berserker-base") - .appendPath("redirect") - .appendQueryParameter("appId", "253") - .appendQueryParameter("loginFrom", "h5") - .appendQueryParameter("synAccessSource", "h5") - .appendQueryParameter("synjones-auth", token) - .appendQueryParameter("type", "app") - .build() - .toString() -} - -private fun isInternalCmbRechargeUrl(url: Uri): Boolean { - val host = url.host.orEmpty().lowercase() - return host == "ahu.edu.cn" || host.endsWith(".ahu.edu.cn") -} - -private fun isAuditedCmbSubmitPage(url: Uri): Boolean = isInternalCmbRechargeUrl(url) && - (url.path.orEmpty().contains("/cashier-mobile/charge") || url.path.orEmpty().contains("/charge-app")) - -private fun openExternalLink(context: android.content.Context, url: String) { - val targetUri = runCatching { Uri.parse(url) }.getOrNull() - if (targetUri == null) { - Toast.makeText(context, "无法打开外部链接", Toast.LENGTH_SHORT).show() - return - } - - try { - context.startActivity(Intent(Intent.ACTION_VIEW, targetUri)) - } catch (_: ActivityNotFoundException) { - Toast.makeText(context, "无法打开外部链接", Toast.LENGTH_SHORT).show() - } -} - -private fun syncYcardCookiesToWebView(webView: WebView) { - val webCookieManager = android.webkit.CookieManager.getInstance() - YcardCookieManager.cookieJar.getAllCookies().forEach { cookie -> - val targetUrl = buildCookieTargetUrl(cookie) - val cookieValue = buildString { - append(cookie.name) - append("=") - append(cookie.value) - append("; Path=") - append(cookie.path) - append("; Domain=") - append(cookie.domain) - if (cookie.secure) append("; Secure") - if (cookie.httpOnly) append("; HttpOnly") - } - webCookieManager.setCookie(targetUrl, cookieValue) - } - webCookieManager.flush() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - webCookieManager.setAcceptThirdPartyCookies(webView, false) - } -} - -private fun buildCookieTargetUrl(cookie: Cookie): String { - val scheme = if (cookie.secure) "https" else "http" - 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 deleted file mode 100644 index f4e4bb2f..00000000 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbRechargePageStyle.kt +++ /dev/null @@ -1,310 +0,0 @@ -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/ElectricityDeposit.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/ElectricityDeposit.kt index 8bbe0182..9cae7ec6 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/ElectricityDeposit.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/ElectricityDeposit.kt @@ -1,694 +1,450 @@ package com.ahu.ahutong.ui.screen.main -import android.widget.Toast -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.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.platform.LocalContext -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import com.ahu.ahutong.data.crawler.PayState -import com.ahu.ahutong.data.dao.AHUCache -import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.ahu.ahutong.data.model.ElectricityController +import com.ahu.ahutong.personalization.action.AppActionId +import com.ahu.ahutong.personalization.ui.rememberBehaviorActionReporter +import com.ahu.ahutong.ui.component.SecurePaymentPasswordDialog +import com.ahu.ahutong.ui.components.AppButton +import com.ahu.ahutong.ui.components.AppButtonVariant +import com.ahu.ahutong.ui.components.AppCircularProgressIndicator +import com.ahu.ahutong.ui.components.AppComponentTokens +import com.ahu.ahutong.ui.components.AppScrollablePageLayout +import com.ahu.ahutong.ui.components.AppSelectField +import com.ahu.ahutong.ui.components.AppSelectOption +import com.ahu.ahutong.ui.components.AppTextField +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.state.CampusDataItem import com.ahu.ahutong.ui.state.ElectricityDepositViewModel -import com.kyant.monet.a1 +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.n1 import com.kyant.monet.withNight -import com.ahu.ahutong.personalization.ui.rememberBehaviorActionReporter -import com.ahu.ahutong.personalization.action.AppActionId import kotlinx.coroutines.delay -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.input.ImeAction - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ElectricityDeposit( + onBack: () -> Unit, + onOpenRecentRooms: () -> Unit, viewModel: ElectricityDepositViewModel = hiltViewModel() ) { - DisposableEffect(viewModel) { - onDispose { viewModel.onPresetSurfaceDisposed() } - } val behaviorReporter = rememberBehaviorActionReporter() - val payState = viewModel.payState.collectAsState() - LaunchedEffect(payState.value) { - when (payState.value) { - is PayState.Succeeded, is PayState.Failed -> { - delay(1000) - viewModel.resetPaymentState() - } - - else -> { - - } - } - } - - val focusManager = LocalFocusManager.current + val payState by viewModel.payState.collectAsState() + val selectedController by viewModel.selectedController.collectAsState() val campusList by viewModel.campusList.collectAsState() val selectedCampus by viewModel.selectedCampus.collectAsState() - val buildingsList by viewModel.buildingsList.collectAsState() val selectedBuilding by viewModel.selectedBuilding.collectAsState() - val floorsList by viewModel.floorsList.collectAsState() val selectedFloor by viewModel.selectedFloor.collectAsState() - val roomsList by viewModel.roomsList.collectAsState() val selectedRoom by viewModel.selectedRoom.collectAsState() - val roomInfo by viewModel.roomInfo.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val errorMessage by viewModel.errorMessage.collectAsState() val historyOptions by viewModel.historyOptions.collectAsState() - val presetCandidates by viewModel.presetCandidates.collectAsState() - - var campusDropdownExpanded by remember { mutableStateOf(false) } - var buildingsDropdownExpanded by remember { mutableStateOf(false) } - var floorsDropdownExpanded by remember { mutableStateOf(false) } - var roomsDropdownExpanded by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current - val context = LocalContext.current - var infoClickCount by remember { mutableStateOf(0) } - var currentToast by remember { mutableStateOf(null) } - fun showToast(msg: String) { - currentToast?.cancel() - currentToast = Toast.makeText(context, msg, Toast.LENGTH_SHORT).also { it.show() } + var amount by rememberSaveable { mutableStateOf("") } + var showPasswordDialog by rememberSaveable { mutableStateOf(false) } + var password by rememberSaveable { mutableStateOf("") } + var passwordError by rememberSaveable { mutableStateOf(null) } + val controllerOptions = remember { + ElectricityController.entries.map { AppSelectOption(it, it.displayName) } } - fun validateBefore(level: Int): Boolean { - val msg = when { - level >= 1 && selectedCampus == null -> "请先选择校区" - level >= 2 && selectedBuilding == null -> "请先选择楼栋" - level >= 3 && selectedFloor == null -> "请先选择楼层" - else -> null - } - return if (msg != null) { - showToast(msg) - false - } else true + val campusOptions = remember(campusList) { + campusList.map { AppSelectOption(it, it.name) } + } + val buildingOptions = remember(buildingsList) { + buildingsList.map { AppSelectOption(it, it.name) } + } + val floorOptions = remember(floorsList) { + floorsList.map { AppSelectOption(it, it.name) } + } + val roomOptions = remember(roomsList) { + roomsList.map { AppSelectOption(it, it.name) } } - val openBuildingMenu = { if (validateBefore(1)) buildingsDropdownExpanded = true } - val openFloorMenu = { if (validateBefore(2)) floorsDropdownExpanded = true } - val openRoomMenu = { if (validateBefore(3)) roomsDropdownExpanded = true } - - var showResetDialog by remember { mutableStateOf(false) } - - var amount by remember { mutableStateOf("") } + LaunchedEffect(payState) { + if (payState is PayState.Succeeded || payState is PayState.Failed) { + delay(PAYMENT_RESULT_DISPLAY_DURATION_MS) + viewModel.resetPaymentState() + } + } - var showDialog by remember { mutableStateOf(false) } - var password by remember { mutableStateOf("") } - var errorMsg by remember { mutableStateOf(null) } + val canPay = (!selectedController.requiresCampus || selectedCampus != null) && + selectedBuilding != null && selectedFloor != null && selectedRoom != null && + amount.toDoubleOrNull()?.let { it > 0.0 } == true && + !isLoading && payState is PayState.Idle - Column( + AppScrollablePageLayout( + title = "电控缴费", + onBack = onBack, modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) - .systemBarsPadding(), - verticalArrangement = Arrangement.spacedBy(24.dp) + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), + bottomPadding = 48.dp ) { - Text( - text = "电控缴费", - modifier = Modifier.padding(24.dp, 32.dp), - style = MaterialTheme.typography.headlineMedium - ) - - presetCandidates.firstOrNull()?.let { candidate -> - LaunchedEffect(candidate.opportunityId, candidate.presetId) { - viewModel.onPresetCandidateVisible(candidate) - } - Text( - text = "使用最近房间", + if (errorMessage != null) { + Column( modifier = Modifier .padding(horizontal = 16.dp) - .clip(SmoothRoundedCornerShape(16.dp)) - .background(90.a1 withNight 30.n1) - .clickable { viewModel.applyPresetCandidate(candidate) } - .padding(horizontal = 16.dp, vertical = 10.dp), - color = 10.n1 withNight 90.n1, - style = MaterialTheme.typography.titleMedium - ) - } - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(16.dp)) - .background(100.n1 withNight 20.n1) - ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .padding(16.dp) .fillMaxWidth() - .clickable { campusDropdownExpanded = true }, - - ) { + .appLiquidGlassSurface( + shape = AppComponentTokens.CardShape, + fallbackColor = MaterialTheme.colorScheme.errorContainer, + level = LiquidGlassSurfaceLevel.Panel + ) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text("电控信息加载失败", style = MaterialTheme.typography.titleMedium) Text( - text = "选择校区", - style = MaterialTheme.typography.titleMedium + text = errorMessage.orEmpty(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium ) - - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { campusDropdownExpanded = true } + AppButton( + onClick = viewModel::retry, + modifier = Modifier.fillMaxWidth(), + variant = AppButtonVariant.Secondary ) { - Text( - text = selectedCampus?.name ?: "请选择校区" - ) - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = "展开校区列表" - ) - - DropdownMenu( - expanded = campusDropdownExpanded, - modifier = Modifier.heightIn(max = 350.dp).background(99.n1 withNight 10.n1), - onDismissRequest = { campusDropdownExpanded = false }, - ) { - campusList.forEach { campus -> - DropdownMenuItem( - text = { Text(campus.name, color = 10.n1 withNight 90.n1) }, - onClick = { - viewModel.onCampusSelected(campus) - campusDropdownExpanded = false - } - ) - } - } + Text("重新加载") } } + } - Row( + if (historyOptions.isNotEmpty()) { + AppButton( + onClick = onOpenRecentRooms, modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - .clickable { openBuildingMenu() }, - horizontalArrangement = Arrangement.SpaceBetween, + .padding(horizontal = 16.dp) + .fillMaxWidth(), + enabled = !isLoading, + variant = AppButtonVariant.Secondary ) { - Text(text = "选择楼栋", style = MaterialTheme.typography.titleMedium) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { openBuildingMenu() } - ) { - Text( - text = selectedBuilding?.name ?: "请选择楼栋" - ) - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = "展开楼栋列表" - ) - - DropdownMenu( - expanded = buildingsDropdownExpanded, - modifier = Modifier.heightIn(max = 450.dp).background(99.n1 withNight 10.n1), - onDismissRequest = { buildingsDropdownExpanded = false }, - ) { - buildingsList.forEach { building -> - DropdownMenuItem( - text = { Text(building.name, color = 10.n1 withNight 90.n1) }, - onClick = { - viewModel.onBuildingSelected(building) - buildingsDropdownExpanded = false - } - ) - } - } - } + Text("最近使用的房间") } + } - Row( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - .clickable { openFloorMenu() }, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(text = "选择楼层", style = MaterialTheme.typography.titleMedium) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { openFloorMenu() }, - ) { - Text( - text = selectedFloor?.name ?: "请选择楼层" - ) - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = "展开楼层列表" - ) - - DropdownMenu( - expanded = floorsDropdownExpanded, - modifier = Modifier.heightIn(max = 450.dp).background(99.n1 withNight 10.n1), - onDismissRequest = { floorsDropdownExpanded = false }, - ) { - floorsList.forEach { floor -> - DropdownMenuItem( - text = { Text(floor.name, color = 10.n1 withNight 90.n1) }, - onClick = { - viewModel.onfloorSelected(floor) - floorsDropdownExpanded = false - } - ) - } - } - } + val loadingSelector = when { + !isLoading -> null + selectedController.requiresCampus && selectedCampus == null -> ElectricitySelectorLevel.Campus + selectedBuilding == null -> ElectricitySelectorLevel.Building + selectedFloor == null -> ElectricitySelectorLevel.Floor + selectedRoom == null -> ElectricitySelectorLevel.Room + else -> ElectricitySelectorLevel.Room + } + Column( + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + AppSelectField( + label = "电控入口", + selected = selectedController, + options = controllerOptions, + onSelected = viewModel::onControllerSelected, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading, + miuixStandalone = true + ) + if (selectedController.requiresCampus) { + ElectricitySelectorField( + label = "校区", + selected = selectedCampus, + options = campusOptions, + onSelected = viewModel::onCampusSelected, + modifier = Modifier, + placeholder = "请选择校区", + enabled = !isLoading, + loading = loadingSelector == ElectricitySelectorLevel.Campus + ) } + ElectricitySelectorField( + label = "楼栋", + selected = selectedBuilding, + options = buildingOptions, + onSelected = viewModel::onBuildingSelected, + modifier = Modifier, + placeholder = if (selectedController.requiresCampus) "请先选择校区" else "请选择楼栋", + enabled = (!selectedController.requiresCampus || selectedCampus != null) && !isLoading, + loading = loadingSelector == ElectricitySelectorLevel.Building + ) + ElectricitySelectorField( + label = "楼层", + selected = selectedFloor, + options = floorOptions, + onSelected = viewModel::onfloorSelected, + modifier = Modifier, + placeholder = "请先选择楼栋", + enabled = selectedBuilding != null && !isLoading, + loading = loadingSelector == ElectricitySelectorLevel.Floor + ) + ElectricitySelectorField( + label = "房间", + selected = selectedRoom, + options = roomOptions, + onSelected = viewModel::onRoomSelected, + modifier = Modifier, + placeholder = "请先选择楼层", + enabled = selectedFloor != null && !isLoading, + loading = loadingSelector == ElectricitySelectorLevel.Room + ) + } - Row( + roomInfo?.takeIf(String::isNotBlank)?.let { info -> + Column( modifier = Modifier - .padding(16.dp) + .padding(horizontal = 16.dp) .fillMaxWidth() - .clickable { openRoomMenu() }, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(text = "选择房间", style = MaterialTheme.typography.titleMedium) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { openRoomMenu() }, - ) { - Text( - text = selectedRoom?.name ?: "请选择房间" + .appLiquidGlassSurface( + shape = AppComponentTokens.CardShape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainer, + level = LiquidGlassSurfaceLevel.Panel ) - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = "展开房间列表" - ) - - DropdownMenu( - expanded = roomsDropdownExpanded, - onDismissRequest = { roomsDropdownExpanded = false }, - modifier = Modifier.heightIn(max = 500.dp).background(99.n1 withNight 10.n1) - ) { - roomsList.forEach { room -> - DropdownMenuItem( - text = { Text(room.name, color = 10.n1 withNight 90.n1) }, - onClick = { - viewModel.onRoomSelected(room) - roomsDropdownExpanded = false - } - ) - } - } - } - } - - if (historyOptions.size == 2) { - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(bottom = 8.dp) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - historyOptions.forEach { item -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - Text( - text = item.label, - modifier = Modifier - .clip(SmoothRoundedCornerShape(16.dp)) - .background(90.a1 withNight 30.n1) - .padding(8.dp) - .clickable { viewModel.selectHistory(item) }, - color = 10.n1 withNight 90.n1, - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - } - - Row( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - // 4. 将 clickable 替换为 combinedClickable - .combinedClickable( - onClick = { - // --- 这里是之前的单击逻辑,保持不变 --- - infoClickCount++ - currentToast?.cancel() - val message = when { - infoClickCount == 1 -> "点击五次查看累计充值记录,长按清空记录" - infoClickCount == 2 -> "再点击三次即可查看累计充值记录" - infoClickCount == 3 -> "再点击两次即可查看累计充值记录" - infoClickCount == 4 -> "再点击一次即可查看累计充值记录" - infoClickCount >= 5 -> { - val chargeInfo = AHUCache.getElectricityChargeInfo() - if (chargeInfo != null) { - "从${chargeInfo.firstChargeDate}起累计电费充值金额为:${ - "%.2f".format( - chargeInfo.totalAmount - ) - }元" - } else { - "暂无充值记录" - } - } - - else -> null - } - if (message != null) { - val toastLength = - if (infoClickCount >= 5) Toast.LENGTH_LONG else Toast.LENGTH_SHORT - val newToast = Toast.makeText(context, message, toastLength) - newToast.show() - currentToast = newToast - } - }, - onLongClick = { - showResetDialog = true - } - ), - horizontalArrangement = Arrangement.SpaceBetween, + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text(text = "信息", style = MaterialTheme.typography.titleMedium) - Text(text = roomInfo?.replace(",", "\n") ?: "") + Text( + text = "房间信息", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = info.replace(",", "\n"), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge + ) } } Column( modifier = Modifier .padding(horizontal = 16.dp) - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(16.dp)) - .background(100.n1 withNight 20.n1), + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Text( text = "缴费金额", - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.titleMedium - + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold ) - - TextField( + AppTextField( value = amount, - onValueChange = { newText -> - if (newText.isEmpty()) { - amount = newText - return@TextField - } - val regex = Regex("^\\d*\\.?\\d{0,2}$") - if (regex.matches(newText)) { - amount = newText + onValueChange = { input -> + if (input.isEmpty() || Regex("^\\d*\\.?\\d{0,2}$").matches(input)) { + amount = input } }, + label = "金额(元)", modifier = Modifier.fillMaxWidth(), - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - placeholder = { Text("请输入金额", color = 30.n1 withNight 70.n1) }, - textStyle = TextStyle(fontSize = 16.sp, color = 10.n1 withNight 90.n1), - + enabled = !isLoading, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done ), - keyboardActions = KeyboardActions( - onDone = { focusManager.clearFocus() } - ), - singleLine = true + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) ) } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Box( - modifier = Modifier - .navigationBarsPadding() - .padding(16.dp) - .clip(SmoothRoundedCornerShape(32.dp)) - .background( - animateColorAsState( - targetValue = when (payState.value) { - is PayState.Idle -> 90.a1 withNight 85.a1 - is PayState.InProgress -> 70.a1 withNight 60.a1 - is PayState.Failed -> Color.Red - is PayState.Succeeded -> 70.a1 withNight 60.a1 - } - ).value - ) - .animateContentSize(spring(stiffness = Spring.StiffnessLow)) + when (val state = payState) { + PayState.Idle -> Unit + PayState.InProgress -> Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + AppCircularProgressIndicator(size = 24.dp, strokeWidth = 3.dp) + Text(" 正在提交缴费", style = MaterialTheme.typography.bodyLarge) + } + is PayState.Succeeded -> Text( + text = "缴费成功,订单号:${state.message}", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyMedium + ) + is PayState.Failed -> Text( + text = "缴费失败:${state.message}", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium + ) + } + AppButton( + onClick = { showPasswordDialog = true }, + modifier = Modifier.fillMaxWidth(), + enabled = canPay ) { - when (payState.value) { - is PayState.Idle -> { - Text( - text = "确认", - modifier = Modifier - .clickable( - role = Role.Button, - onClick = { - when { - selectedCampus == null -> showToast("请先选择校区") - selectedBuilding == null -> showToast("请先选择楼栋") - selectedFloor == null -> showToast("请先选择楼层") - selectedRoom == null -> showToast("请先选择房间") - amount.isBlank() -> showToast("请输入缴费金额") - (amount.toDoubleOrNull() ?: 0.0) <= 0.0 -> showToast("请输入有效金额") - else -> showDialog = true - } - } - ) - .padding(24.dp, 16.dp), - color = 0.n1, - style = MaterialTheme.typography.titleMedium - ) - } + Text(if (payState is PayState.InProgress) "正在支付" else "确认缴费") + } + } + } - is PayState.InProgress -> { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalArrangement = Arrangement.spacedBy(24.dp), - verticalAlignment = Alignment.CenterVertically - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = 100.n1, - strokeWidth = 6.dp - ) - Text( - text = "支付中...", - modifier = Modifier.padding(4.dp), - color = 100.n1, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium - ) - } - } + if (showPasswordDialog) { + SecurePaymentPasswordDialog( + password = password, + onPasswordChange = { + password = it + passwordError = null + }, + title = "请输入校园卡密码", + errorMessage = passwordError, + onDismissRequest = { + showPasswordDialog = false + password = "" + passwordError = null + }, + onConfirm = { confirmedPassword -> + if (confirmedPassword.length == 6) { + showPasswordDialog = false + behaviorReporter.organic(AppActionId.CONFIRM_ELECTRICITY_PAYMENT) + viewModel.pay(amount, confirmedPassword) + } else { + passwordError = "密码必须是 6 位数字" + } + } + ) + } +} - is PayState.Succeeded -> { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(24.dp) +@Composable +fun ElectricityRecentRooms( + onBack: () -> Unit, + onRoomSelected: () -> Unit, + viewModel: ElectricityDepositViewModel +) { + val historyOptions by viewModel.historyOptions.collectAsState() + AppScrollablePageLayout( + title = "最近使用的房间", + onBack = onBack, + modifier = Modifier + .fillMaxSize() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), + bottomPadding = 48.dp + ) { + if (historyOptions.isEmpty()) { + Text( + text = "暂无最近使用的房间", + modifier = Modifier.padding(horizontal = 20.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge + ) + } else { + historyOptions.forEach { item -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AppButton( + onClick = { + viewModel.selectHistory(item) + onRoomSelected() + }, + modifier = Modifier.weight(1f), + variant = AppButtonVariant.Secondary + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp) ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = 100.n1 - ) + Text(item.label, style = MaterialTheme.typography.titleSmall) Text( - text = "支付成功! 订单号:${(payState.value as PayState.Succeeded).message}", - modifier = Modifier - .padding(4.dp) - .clickable { - - }, - color = 100.n1, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.headlineSmall + text = listOfNotNull( + item.selection.campus?.name, + item.selection.building?.name, + item.selection.floor?.name + ).joinToString(" · "), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + maxLines = 1 ) } } - - is PayState.Failed -> { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(24.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(56.dp), - tint = 100.n1 - ) - Text( - text = "支付失败!", - modifier = Modifier.padding(4.dp), - color = 100.n1, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.headlineSmall - ) - } + AppButton( + onClick = { viewModel.deleteHistory(item) }, + variant = AppButtonVariant.Destructive + ) { + Text("删除") } } } } - if (showDialog) { - AlertDialog( - containerColor = 100.n1 withNight 20.n1, - textContentColor = 10.n1 withNight 90.n1, - onDismissRequest = { showDialog = false }, - title = { Text("请输入校园卡密码", color = 10.n1 withNight 90.n1) }, - text = { - Column { - OutlinedTextField( - value = password, - onValueChange = { input -> - if (input.length <= 6 && input.all { it.isDigit() }) { - password = input - errorMsg = null - } - }, - label = { Text("密码 (6位数字)", color = 40.n1 withNight 60.n1) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), - visualTransformation = PasswordVisualTransformation(), - isError = errorMsg != null, - colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = 10.n1 withNight 90.n1, - unfocusedTextColor = 10.n1 withNight 90.n1, - focusedBorderColor = 20.n1 withNight 80.n1 - ) - ) - if (errorMsg != null) { - Text( - text = errorMsg!!, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall - ) - } - } - }, - confirmButton = { - TextButton(onClick = { - if (password.length == 6) { - showDialog = false - // 调用 ViewModel 中的 pay 函数 - behaviorReporter.organic(AppActionId.CONFIRM_ELECTRICITY_PAYMENT) - viewModel.pay(amount, password) - } else { - errorMsg = "密码必须是6位数字" - } - }) { - Text("确认", color = 10.n1 withNight 90.n1) - } - }, - dismissButton = { - TextButton(onClick = { - showDialog = false - password = "" - errorMsg = null - }) { - Text("取消", color = 10.n1 withNight 90.n1) - } - } - ) - } - if (showResetDialog) { - AlertDialog( - containerColor = 100.n1 withNight 20.n1, - titleContentColor = 10.n1 withNight 90.n1, - textContentColor = 40.n1 withNight 70.n1, - onDismissRequest = { showResetDialog = false }, - title = { Text("确认操作") }, - text = { Text("您确定要将累计充值金额清零吗?此操作不可撤销。") }, - confirmButton = { - TextButton( - onClick = { - AHUCache.clearElectricityChargeInfo() - Toast.makeText(context, "累计记录已清零", Toast.LENGTH_SHORT).show() - showResetDialog = false - } - ) { - Text("确认", color = 40.a1 withNight 80.a1) - } - }, - dismissButton = { - TextButton( - onClick = { showResetDialog = false } - ) { - Text("取消", color = 40.a1 withNight 80.a1) - } - } + } +} + +@Composable +private fun ElectricitySelectorField( + label: String, + selected: CampusDataItem?, + options: List>, + onSelected: (CampusDataItem) -> Unit, + modifier: Modifier, + placeholder: String, + enabled: Boolean, + loading: Boolean +) { + Box(modifier = modifier.fillMaxWidth()) { + AppSelectField( + label = label, + selected = selected, + options = options, + onSelected = onSelected, + modifier = Modifier.fillMaxWidth(), + placeholder = placeholder, + enabled = enabled, + miuixStandalone = true + ) + if (loading) { + AppCircularProgressIndicator( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 48.dp), + size = 20.dp, + strokeWidth = 2.5.dp ) } } } + +private enum class ElectricitySelectorLevel { + Campus, + Building, + Floor, + Room +} + +private const val PAYMENT_RESULT_DISPLAY_DURATION_MS = 3_000L diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Evaluation.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Evaluation.kt index 2d7bf2a9..dd3ed48a 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Evaluation.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Evaluation.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -28,11 +29,7 @@ import androidx.compose.material.icons.outlined.Person import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import com.ahu.ahutong.ui.components.AppCircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -57,6 +54,7 @@ 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.RectangleShape import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -65,15 +63,29 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.ahu.ahutong.data.model.EvalQuestion import com.ahu.ahutong.data.model.EvalTask import com.ahu.ahutong.data.model.EvalTeacher +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.components.AppToggle +import com.ahu.ahutong.ui.components.AppButton +import com.ahu.ahutong.ui.components.AppCard +import com.ahu.ahutong.ui.components.AppButtonVariant +import com.ahu.ahutong.ui.components.AppHeaderIconButton +import com.ahu.ahutong.ui.components.AppLazyPageLayout +import com.ahu.ahutong.ui.components.AppSelectField +import com.ahu.ahutong.ui.components.AppSelectOption import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.EvaluationViewModel +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.withNight +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.icons.useful.Settings @Composable fun Evaluation( - viewModel: EvaluationViewModel = viewModel() + viewModel: EvaluationViewModel = viewModel(), + onBack: (() -> Unit)? = null ) { LaunchedEffect(Unit) { viewModel.loadSemesters() @@ -87,7 +99,6 @@ fun Evaluation( LaunchedEffect(errorMessage) { errorMessage?.let { Toast.makeText(context, it, Toast.LENGTH_LONG).show() - viewModel.errorMessage.value = null } } @@ -101,29 +112,34 @@ fun Evaluation( if (currentTask != null) { EvaluationFormScreen(viewModel) } else { - EvaluationListScreen(viewModel) + EvaluationListScreen(viewModel, onBack) } } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun EvaluationListScreen(viewModel: EvaluationViewModel) { +private fun EvaluationListScreen( + viewModel: EvaluationViewModel, + onBack: (() -> Unit)? +) { val semesters by viewModel.semesters.collectAsState() val selectedSemesterId by viewModel.selectedSemesterId.collectAsState() val taskItems by viewModel.taskItems.collectAsState() val isLoading by viewModel.isLoading.collectAsState() val isSubmitting by viewModel.isSubmitting.collectAsState() val isBulkSubmitting by viewModel.isBulkSubmitting.collectAsState() + val errorMessage by viewModel.errorMessage.collectAsState() - var semesterExpanded by remember { mutableStateOf(false) } var presetDialogShown by remember { mutableStateOf(false) } var confirmBulkSubmitShown by remember { mutableStateOf(false) } - val presetTargetCount = taskItems.sumOf { item -> - item.taskList.sumOf { task -> - if (!task.timeStatus) { - 0 - } else { - task.teachers.count { teacher -> teacher.status == "TO_REVIEW" } + val presetTargetCount = remember(taskItems) { + taskItems.sumOf { item -> + item.taskList.sumOf { task -> + if (!task.timeStatus) { + 0 + } else { + task.teachers.count { teacher -> teacher.status == "TO_REVIEW" } + } } } } @@ -136,9 +152,18 @@ private fun EvaluationListScreen(viewModel: EvaluationViewModel) { ) } if (confirmBulkSubmitShown) { + val dialogShape = SmoothRoundedCornerShape(28.dp) AlertDialog( + modifier = Modifier.appLiquidGlassSurface( + shape = dialogShape, + fallbackColor = 100.n1 withNight 20.n1, + level = LiquidGlassSurfaceLevel.Floating, + backdropSamplingEnabled = false + ), onDismissRequest = { confirmBulkSubmitShown = false }, - containerColor = 100.n1 withNight 20.n1, + shape = dialogShape, + containerColor = Color.Transparent, + tonalElevation = 0.dp, titleContentColor = 0.n1 withNight 100.n1, textContentColor = 30.n1 withNight 90.n1, title = { Text("确认批量评教") }, @@ -174,150 +199,152 @@ private fun EvaluationListScreen(viewModel: EvaluationViewModel) { ) } - Column( + AppLazyPageLayout( + title = "评教", + onBack = onBack, modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) - .systemBarsPadding() - .padding(bottom = 96.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), + bottomPadding = 48.dp, + verticalArrangement = Arrangement.spacedBy(12.dp), + actions = { + AppHeaderIconButton( + imageVector = Icons.Filled.Settings, + miuixImageVector = MiuixIcons.Useful.Settings, + contentDescription = "评教预设", + onClick = { presetDialogShown = true } + ) + } ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 24.dp, top = 32.dp, end = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "教评", - modifier = Modifier.weight(1f), - color = 0.n1 withNight 100.n1, - style = MaterialTheme.typography.headlineMedium + item(key = "semester") { + AppSelectField( + label = "选择学期", + selected = selectedSemesterId, + options = semesters.map { semester -> + AppSelectOption(semester.id, semester.nameZh) + }, + onSelected = { semesterId -> + viewModel.selectedSemesterId.value = semesterId + viewModel.loadEvaluationList() + }, + modifier = Modifier.padding(horizontal = 16.dp), + enabled = !isLoading && !isSubmitting && !isBulkSubmitting, + miuixStandalone = true ) - Box { - TextButton(onClick = { semesterExpanded = true }) { - val selected = semesters.firstOrNull { it.id == selectedSemesterId } - Text( - text = selected?.nameZh ?: "选择学期", + } + + item(key = "bulk-submit") { + AppButton( + onClick = { confirmBulkSubmitShown = true }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + enabled = hasPresetTargets && !isLoading && !isSubmitting && !isBulkSubmitting, + variant = AppButtonVariant.Secondary + ) { + if (isBulkSubmitting) { + AppCircularProgressIndicator( + size = 16.dp, + strokeWidth = 2.dp, color = 40.a1 withNight 80.a1 ) + } else { + Text("按预设完成全部") } - DropdownMenu( - expanded = semesterExpanded, - onDismissRequest = { semesterExpanded = false }, - containerColor = 100.n1 withNight 20.n1 - ) { - semesters.forEach { semester -> - DropdownMenuItem( - text = { - Text( - text = semester.nameZh, - color = 0.n1 withNight 100.n1 - ) - }, - onClick = { - viewModel.selectedSemesterId.value = semester.id - viewModel.loadEvaluationList() - semesterExpanded = false - }, - leadingIcon = if (semester.id == selectedSemesterId) { - { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - tint = 40.a1 withNight 80.a1 - ) - } - } else null - ) - } - } - } - IconButton(onClick = { presetDialogShown = true }) { - Icon( - imageVector = Icons.Filled.Settings, - contentDescription = "评教预设", - tint = 0.n1 withNight 100.n1 - ) - } - } - - OutlinedButton( - onClick = { confirmBulkSubmitShown = true }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - enabled = hasPresetTargets && !isLoading && !isSubmitting && !isBulkSubmitting, - shape = SmoothRoundedCornerShape(12.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = 40.a1 withNight 80.a1, - disabledContentColor = 60.n1 withNight 50.n1 - ) - ) { - if (isBulkSubmitting) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = 40.a1 withNight 80.a1 - ) - } else { - Text("按预设完成全部") } } if (isLoading && taskItems.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(32.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() + item(key = "loading") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + AppCircularProgressIndicator() + } } } - taskItems.forEach { item -> - item.taskList.forEach { task -> - task.teachers.forEach { teacher -> - EvaluationCard( - task = task, - teacher = teacher, - courseName = item.courseName, - lessonName = item.lessonNameZh, + if (!errorMessage.isNullOrBlank()) { + item(key = "error") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(16.dp), + fallbackColor = MaterialTheme.colorScheme.errorContainer, + level = LiquidGlassSurfaceLevel.Panel + ) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = errorMessage.orEmpty(), + color = MaterialTheme.colorScheme.onErrorContainer + ) + AppButton( onClick = { - viewModel.enterEvaluation( - task = task, - teacher = teacher, - courseName = item.courseName, - lessonName = item.lessonNameZh - ) + if (semesters.isEmpty()) viewModel.loadSemesters() + else viewModel.loadEvaluationList() }, - onPresetClick = { - viewModel.quickSubmitWithPreset( - task = task, - teacher = teacher, - courseName = item.courseName, - lessonName = item.lessonNameZh - ) - }, - presetEnabled = !isSubmitting && !isBulkSubmitting - ) + modifier = Modifier.fillMaxWidth(), + variant = AppButtonVariant.Secondary + ) { Text("重试") } } } } - if (!isLoading && taskItems.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(32.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "暂无待评教课程", - color = 40.n1 withNight 80.n1, - style = MaterialTheme.typography.bodyMedium - ) + taskItems.forEachIndexed { itemIndex, taskItem -> + taskItem.taskList.forEachIndexed { taskIndex, task -> + task.teachers.forEachIndexed { teacherIndex, teacher -> + item( + key = "${taskItem.lessonId}:${task.stdSumTaskId}:${teacher.teacherId}:$itemIndex:$taskIndex:$teacherIndex" + ) { + EvaluationCard( + task = task, + teacher = teacher, + courseName = taskItem.courseName, + lessonName = taskItem.lessonNameZh, + onClick = { + viewModel.enterEvaluation( + task = task, + teacher = teacher, + courseName = taskItem.courseName, + lessonName = taskItem.lessonNameZh + ) + }, + onPresetClick = { + viewModel.quickSubmitWithPreset( + task = task, + teacher = teacher, + courseName = taskItem.courseName, + lessonName = taskItem.lessonNameZh + ) + }, + presetEnabled = !isSubmitting && !isBulkSubmitting + ) + } + } + } + } + + if (!isLoading && taskItems.isEmpty() && errorMessage.isNullOrBlank()) { + item(key = "empty") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "暂无待评教课程", + color = 40.n1 withNight 80.n1, + style = MaterialTheme.typography.bodyMedium + ) + } } } } @@ -335,19 +362,15 @@ private fun EvaluationCard( ) { val reviewed = teacher.status != "TO_REVIEW" - Card( + AppCard( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp) - .clip(SmoothRoundedCornerShape(16.dp)) - .clickable(enabled = !reviewed && task.timeStatus, onClick = onClick), - colors = CardDefaults.cardColors( - containerColor = 100.n1 withNight 20.n1 - ), - shape = SmoothRoundedCornerShape(16.dp) + .padding(horizontal = 16.dp), + shape = SmoothRoundedCornerShape(20.dp), + enabled = !reviewed && task.timeStatus, + onClick = onClick ) { Column( - modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row( @@ -400,19 +423,13 @@ private fun EvaluationCard( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End ) { - OutlinedButton( + AppButton( onClick = onPresetClick, enabled = !reviewed && task.timeStatus && presetEnabled, - shape = SmoothRoundedCornerShape(10.dp), - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = 40.a1 withNight 80.a1, - disabledContentColor = 60.n1 withNight 50.n1 - ) + variant = AppButtonVariant.Secondary ) { Text( text = "按预设完成", - color = 40.a1 withNight 80.a1, style = MaterialTheme.typography.labelMedium ) } @@ -455,153 +472,99 @@ private fun EvaluationFormScreen(viewModel: EvaluationViewModel) { } } - Column( + AppLazyPageLayout( + title = currentCourseName.ifBlank { "课程评教" }, + onBack = { viewModel.backToList() }, modifier = Modifier .fillMaxSize() - .systemBarsPadding() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), + bottomPadding = 48.dp, + verticalArrangement = Arrangement.spacedBy(16.dp), + actions = { + AppHeaderIconButton( + imageVector = Icons.Filled.Settings, + miuixImageVector = MiuixIcons.Useful.Settings, + contentDescription = "评教预设", + onClick = { presetDialogShown = true } + ) + } ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = { viewModel.backToList() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "返回", - tint = 0.n1 withNight 100.n1 - ) - } - Column(modifier = Modifier.weight(1f)) { - Text( - text = currentCourseName, - color = 0.n1 withNight 100.n1, - fontWeight = FontWeight.SemiBold, - style = MaterialTheme.typography.titleMedium - ) - Text( - text = "${currentTeacher?.teacherName.orEmpty()} · $currentLessonName", - color = 30.n1 withNight 90.n1, - style = MaterialTheme.typography.bodySmall - ) - } - IconButton(onClick = { presetDialogShown = true }) { - Icon( - imageVector = Icons.Filled.Settings, - contentDescription = "评教预设", - tint = 0.n1 withNight 100.n1 - ) - } + item(key = "teacher") { + Text( + text = "${currentTeacher?.teacherName.orEmpty()} · $currentLessonName", + modifier = Modifier.padding(horizontal = 20.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium + ) } - HorizontalDivider(color = 90.n1 withNight 30.n1, thickness = 0.5.dp) - if (isLoading && questions.isEmpty()) { - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() + item(key = "loading") { + Box( + modifier = Modifier.fillMaxWidth().padding(48.dp), + contentAlignment = Alignment.Center + ) { AppCircularProgressIndicator() } } } else { - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Spacer(Modifier.height(4.dp)) + item(key = "preset-actions") { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - OutlinedButton( + AppButton( onClick = { viewModel.applyPresetToCurrent() }, modifier = Modifier.weight(1f), enabled = questions.isNotEmpty() && !isSubmitting, - shape = SmoothRoundedCornerShape(12.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = 40.a1 withNight 80.a1, - disabledContentColor = 60.n1 withNight 50.n1 - ) - ) { - Text("套用预设") - } - Button( + variant = AppButtonVariant.Secondary + ) { Text("套用预设") } + AppButton( onClick = { viewModel.submitCurrentWithPreset() }, modifier = Modifier.weight(1f), - enabled = questions.isNotEmpty() && !isSubmitting, - shape = SmoothRoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = 90.a1 withNight 30.a1, - contentColor = 100.n1 withNight 100.n1, - disabledContainerColor = 80.n1 withNight 25.n1, - disabledContentColor = 50.n1 withNight 60.n1 + enabled = questions.isNotEmpty() && !isSubmitting + ) { Text("预设提交") } + } + } + questions.forEachIndexed { index, question -> + item(key = "question:${question.attribute.id}:$index") { + Box(modifier = Modifier.padding(horizontal = 16.dp)) { + QuestionCard( + question = question, + selectedOptionId = answers[question.attribute.id.toString()], + textAnswer = textAnswers[question.attribute.id.toString()].orEmpty(), + onSelect = { optionId -> + viewModel.setAnswer(question.attribute.id.toString(), optionId) + }, + onTextChange = { text -> + viewModel.setTextAnswer(question.attribute.id.toString(), text) + } ) - ) { - Text("预设提交") } } - questions.forEach { question -> - QuestionCard( - question = question, - selectedOptionId = answers[question.attribute.id.toString()], - textAnswer = textAnswers[question.attribute.id.toString()].orEmpty(), - onSelect = { optionId -> - viewModel.setAnswer(question.attribute.id.toString(), optionId) - }, - onTextChange = { text -> - viewModel.setTextAnswer(question.attribute.id.toString(), text) - } - ) - } - Spacer(Modifier.height(80.dp)) } } - Surface( - modifier = Modifier.fillMaxWidth(), - color = 100.n1 withNight 20.n1, - shadowElevation = 4.dp - ) { + item(key = "submit-actions") { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp) - .navigationBarsPadding(), + .padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - OutlinedButton( + AppButton( onClick = { viewModel.backToList() }, modifier = Modifier.weight(1f), - shape = SmoothRoundedCornerShape(12.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = 40.a1 withNight 80.a1, - disabledContentColor = 60.n1 withNight 50.n1 - ) + variant = AppButtonVariant.Secondary ) { Text("取消") } - Button( + AppButton( onClick = { viewModel.submit(false) }, modifier = Modifier.weight(1f), - enabled = !isSubmitting && questions.isNotEmpty(), - shape = SmoothRoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = 90.a1 withNight 30.a1, - contentColor = 100.n1 withNight 100.n1, - disabledContainerColor = 80.n1 withNight 25.n1, - disabledContentColor = 50.n1 withNight 60.n1 - ) + enabled = !isSubmitting && questions.isNotEmpty() ) { if (isSubmitting) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), + AppCircularProgressIndicator( + size = 16.dp, strokeWidth = 2.dp, color = Color.White ) @@ -609,17 +572,10 @@ private fun EvaluationFormScreen(viewModel: EvaluationViewModel) { Text("提交") } } - Button( + AppButton( onClick = { viewModel.submit(true) }, modifier = Modifier.weight(1f), - enabled = !isSubmitting && questions.isNotEmpty(), - shape = SmoothRoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = 90.a1 withNight 30.n1, - contentColor = 100.n1 withNight 100.n1, - disabledContainerColor = 80.n1 withNight 25.n1, - disabledContentColor = 50.n1 withNight 60.n1 - ) + enabled = !isSubmitting && questions.isNotEmpty() ) { Text("匿名") } @@ -651,9 +607,18 @@ private fun EvaluationPresetDialog( viewModel.loadPresetQuestions() } + val dialogShape = SmoothRoundedCornerShape(28.dp) AlertDialog( + modifier = Modifier.appLiquidGlassSurface( + shape = dialogShape, + fallbackColor = 100.n1 withNight 20.n1, + level = LiquidGlassSurfaceLevel.Floating, + backdropSamplingEnabled = false + ), onDismissRequest = onDismiss, - containerColor = 100.n1 withNight 20.n1, + shape = dialogShape, + containerColor = Color.Transparent, + tonalElevation = 0.dp, titleContentColor = 0.n1 withNight 100.n1, textContentColor = 30.n1 withNight 90.n1, title = { @@ -674,9 +639,10 @@ private fun EvaluationPresetDialog( color = 0.n1 withNight 100.n1, style = MaterialTheme.typography.bodyLarge ) - Switch( + AppToggle( checked = anonymous, - onCheckedChange = { anonymous = it } + onCheckedChange = { anonymous = it }, + contentDescription = "匿名提交" ) } @@ -688,7 +654,7 @@ private fun EvaluationPresetDialog( .padding(24.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + AppCircularProgressIndicator() } } presetQuestions.isEmpty() -> { @@ -823,6 +789,9 @@ private fun PresetQuestionEditor( colors = OutlinedTextFieldDefaults.colors( focusedTextColor = 0.n1 withNight 100.n1, unfocusedTextColor = 0.n1 withNight 100.n1, + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, focusedBorderColor = MaterialTheme.colorScheme.primary, unfocusedBorderColor = 90.n1 withNight 30.n1, cursorColor = 90.a1 withNight 90.a1 @@ -851,15 +820,11 @@ private fun QuestionCard( ) { val attr = question.attribute - Card( + AppCard( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = 100.n1 withNight 20.n1 - ), - shape = SmoothRoundedCornerShape(16.dp) + shape = SmoothRoundedCornerShape(20.dp) ) { Column( - modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -878,7 +843,11 @@ private fun QuestionCard( fontWeight = FontWeight.Medium ) if (attr.required) { - Text(text = "*", color = Color(0xFFE53935), fontSize = 14.sp) + Text( + text = "*", + color = MaterialTheme.colorScheme.error, + fontSize = 14.sp + ) } } @@ -944,6 +913,9 @@ private fun QuestionCard( colors = OutlinedTextFieldDefaults.colors( focusedTextColor = 0.n1 withNight 100.n1, unfocusedTextColor = 0.n1 withNight 100.n1, + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, focusedBorderColor = MaterialTheme.colorScheme.primary, unfocusedBorderColor = 90.n1 withNight 30.n1, focusedPlaceholderColor = 50.n1 withNight 70.n1, diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Exam.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Exam.kt index 99e2bce3..f9f09eea 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Exam.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Exam.kt @@ -33,7 +33,7 @@ import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.Schedule -import androidx.compose.material3.CircularProgressIndicator +import com.ahu.ahutong.ui.components.AppCircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -62,9 +62,15 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.ahu.ahutong.R import com.ahu.ahutong.data.dao.AHUCache import com.ahu.ahutong.data.mock.MockScenarioController +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.AppHeaderIconButton +import com.ahu.ahutong.ui.components.AppScrollablePageLayout +import com.ahu.ahutong.ui.components.AppSearchField +import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.ExamViewModel import com.ahu.ahutong.ui.state.RefreshState +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.withNight @@ -76,11 +82,14 @@ import java.time.format.DateTimeFormatter import com.ahu.ahutong.personalization.ui.rememberBehaviorActionReporter import com.ahu.ahutong.personalization.action.AppActionId import com.ahu.ahutong.personalization.context.ExamDistanceBucket +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.icons.useful.Refresh @OptIn(ExperimentalMaterial3Api::class) @Composable fun Exam( - examViewModel: ExamViewModel = viewModel() + examViewModel: ExamViewModel = viewModel(), + onBack: (() -> Unit)? = null ) { val behaviorReporter = rememberBehaviorActionReporter() LaunchedEffect(Unit) { @@ -131,88 +140,24 @@ fun Exam( exam.orEmpty() } - Column( + AppScrollablePageLayout( + title = stringResource(id = R.string.exam), + onBack = onBack, modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) - .systemBarsPadding() - .padding(bottom = 80.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), + bottomPadding = 48.dp, + actions = { RefreshButton(examViewModel) } ) { - // 标题栏 / 搜索栏 - if (isSearchActive) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = { - isSearchActive = false - searchQuery = "" - }) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = 0.n1 withNight 100.n1 - ) - } - TextField( - value = searchQuery, - onValueChange = { searchQuery = it }, - modifier = Modifier.weight(1f).padding(horizontal = 8.dp), - placeholder = { - Text("搜索课程名称…", color = 50.n1 withNight 70.n1) - }, - singleLine = true, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - focusedTextColor = 0.n1 withNight 100.n1, - unfocusedTextColor = 0.n1 withNight 100.n1, - cursorColor = 90.a1 withNight 90.a1, - ), - trailingIcon = if (searchQuery.isNotEmpty()) { - { - IconButton(onClick = { searchQuery = "" }) { - Icon( - Icons.Default.Close, - contentDescription = "Clear", - tint = 50.n1 withNight 80.n1 - ) - } - } - } else null - ) - } - } else { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 24.dp, end = 16.dp, top = 24.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(id = R.string.exam), - style = MaterialTheme.typography.headlineMedium, - color = 0.n1 withNight 100.n1 - ) - Row { - IconButton(onClick = { isSearchActive = true }) { - Icon( - Icons.Default.Search, - contentDescription = "搜索", - tint = 0.n1 withNight 100.n1 - ) - } - RefreshButton(examViewModel) - } - } - } + AppSearchField( + value = searchQuery, + onValueChange = { + searchQuery = it + isSearchActive = it.isNotBlank() + }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + placeholder = "搜索课程名称…" + ) if (isLoading != true) { if (!filteredExams.isNullOrEmpty()) { @@ -241,8 +186,11 @@ fun Exam( Row( modifier = Modifier .fillMaxWidth() - .clip(SmoothRoundedCornerShape(12.dp)) - .background(100.n1 withNight 20.n1) + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(12.dp), + fallbackColor = 100.n1 withNight 20.n1, + level = LiquidGlassSurfaceLevel.Control + ) .clickable { showFinished = !showFinished } .padding(horizontal = 20.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, @@ -302,8 +250,8 @@ fun Exam( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { - CircularProgressIndicator( - modifier = Modifier.size(32.dp), + AppCircularProgressIndicator( + size = 32.dp, strokeWidth = 3.dp, color = 90.a1 withNight 90.a1 ) @@ -345,8 +293,11 @@ private fun ExamCard( Column( modifier = Modifier .fillMaxWidth() - .clip(SmoothRoundedCornerShape(16.dp)) - .background(100.n1 withNight 20.n1) + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(20.dp), + fallbackColor = MaterialTheme.colorScheme.surfaceContainer, + level = LiquidGlassSurfaceLevel.Panel + ) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp) ) { @@ -416,8 +367,8 @@ private fun RefreshButton(examViewModel: ExamViewModel) { verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp) ) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), + AppCircularProgressIndicator( + size = 16.dp, strokeWidth = 2.dp, color = 90.a1 withNight 90.a1 ) @@ -436,12 +387,15 @@ private fun RefreshButton(examViewModel: ExamViewModel) { } } RefreshState.IDLE -> { - IconButton(onClick = { - behaviorReporter.organic(AppActionId.MANUAL_REFRESH_EXAM) - examViewModel.loadExam(isRefresh = true) - }) { - Icon(Icons.Default.Refresh, "刷新", tint = 0.n1 withNight 100.n1) - } + AppHeaderIconButton( + imageVector = Icons.Default.Refresh, + miuixImageVector = MiuixIcons.Useful.Refresh, + contentDescription = "刷新考试", + onClick = { + behaviorReporter.organic(AppActionId.MANUAL_REFRESH_EXAM) + examViewModel.loadExam(isRefresh = true) + } + ) } } } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/FreeClassroom.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/FreeClassroom.kt index ef1850b8..36107460 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/FreeClassroom.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/FreeClassroom.kt @@ -1,74 +1,77 @@ package com.ahu.ahutong.ui.screen.main -import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.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.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.Alignment +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.ahu.ahutong.R +import com.ahu.ahutong.data.crawler.model.jwxt.FreeRoom import com.ahu.ahutong.data.dao.AHUCache import com.ahu.ahutong.data.mock.MockScenarioController +import com.ahu.ahutong.ui.components.AppButton +import com.ahu.ahutong.ui.components.AppButtonVariant +import com.ahu.ahutong.ui.components.AppCircularProgressIndicator +import com.ahu.ahutong.ui.components.AppFilterChip +import com.ahu.ahutong.ui.components.AppHeaderIconButton +import com.ahu.ahutong.ui.components.AppLazyPageLayout +import com.ahu.ahutong.ui.components.AppSelectField +import com.ahu.ahutong.ui.components.AppSelectOption +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.FreeClassroomViewModel -import com.kyant.capsule.ContinuousCapsule -import com.kyant.monet.a1 +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.n1 import com.kyant.monet.withNight import java.time.LocalDate import java.time.format.DateTimeFormatter -import java.util.Calendar -import androidx.compose.foundation.layout.Spacer -import androidx.compose.ui.text.style.TextAlign @Composable fun FreeClassroom( + onBack: (() -> Unit)? = null, freeClassroomViewModel: FreeClassroomViewModel = hiltViewModel() ) { DisposableEffect(freeClassroomViewModel) { onDispose { freeClassroomViewModel.onPresetSurfaceDisposed() } } + val campusOptions = freeClassroomViewModel.campusOptions val selectedCampusId by freeClassroomViewModel.selectedCampusId.collectAsState() val buildings by freeClassroomViewModel.buildings.collectAsState() @@ -78,12 +81,13 @@ fun FreeClassroom( val endDate by freeClassroomViewModel.endDate.collectAsState() val isLoadingBuildings by freeClassroomViewModel.isLoadingBuildings.collectAsState() val isSearching by freeClassroomViewModel.isSearching.collectAsState() + val hasSearched by freeClassroomViewModel.hasSearched.collectAsState() val rooms by freeClassroomViewModel.freeRooms.collectAsState() val errorMessage by freeClassroomViewModel.errorMessage.collectAsState() val presetCandidates by freeClassroomViewModel.presetCandidates.collectAsState() - val context = LocalContext.current val mockRefreshRevision by MockScenarioController.refreshRevisions().collectAsState() - var isFilterCollapsed by rememberSaveable { mutableStateOf(false) } + + var filtersExpanded by rememberSaveable { mutableStateOf(true) } var showStartDatePicker by remember { mutableStateOf(false) } var showEndDatePicker by remember { mutableStateOf(false) } @@ -93,312 +97,254 @@ fun FreeClassroom( } } - LaunchedEffect(errorMessage) { - errorMessage?.let { - Toast.makeText(context, it, Toast.LENGTH_LONG).show() - freeClassroomViewModel.errorMessage.value = null - } + if (showStartDatePicker) { + MyDatePickerDialog( + initialDate = startDate, + minDate = LocalDate.now(), + onDateSelected = { + freeClassroomViewModel.setStartDate(it) + showStartDatePicker = false + }, + onDismiss = { showStartDatePicker = false } + ) + } + if (showEndDatePicker) { + MyDatePickerDialog( + initialDate = endDate, + minDate = startDate, + onDateSelected = { + freeClassroomViewModel.setEndDate(it) + showEndDatePicker = false + }, + onDismiss = { showEndDatePicker = false } + ) } - Column( + AppLazyPageLayout( + title = stringResource(id = R.string.free_classroom), + onBack = onBack, modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) - .systemBarsPadding() - .padding(bottom = 96.dp), - verticalArrangement = Arrangement.Top + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1) + , + bottomPadding = 112.dp, + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text( - text = stringResource(id = R.string.free_classroom), - modifier = Modifier - .fillMaxWidth() - .padding(24.dp, 32.dp), - style = MaterialTheme.typography.headlineMedium - ) - presetCandidates.firstOrNull()?.let { candidate -> - LaunchedEffect(candidate.opportunityId, candidate.presetId) { - freeClassroomViewModel.onPresetCandidateVisible(candidate) + item(key = "preset-${candidate.opportunityId}-${candidate.presetId}") { + LaunchedEffect(candidate.opportunityId, candidate.presetId) { + freeClassroomViewModel.onPresetCandidateVisible(candidate) + } + AppButton( + onClick = { freeClassroomViewModel.applyPresetCandidate(candidate) }, + modifier = Modifier.padding(horizontal = 16.dp), + variant = AppButtonVariant.Secondary + ) { Text("使用常用条件") } } - Text( - text = "使用常用条件", - modifier = Modifier - .padding(horizontal = 24.dp) - .clip(ContinuousCapsule) - .background(90.a1) - .clickable { freeClassroomViewModel.applyPresetCandidate(candidate) } - .padding(horizontal = 16.dp, vertical = 10.dp), - color = 0.n1, - style = MaterialTheme.typography.titleMedium - ) } - - Spacer(modifier = Modifier.height(24.dp)) - AnimatedVisibility( - visible = !isFilterCollapsed, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { - FilterCard(title = "选择校区") { - HorizontalChipRow { - items(campusOptions) { campus -> - FilterChip( - text = campus.name, - selected = selectedCampusId == campus.id, - onClick = { freeClassroomViewModel.selectCampus(campus.id) }, - isSingle = true + item { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(24.dp), + fallbackColor = MaterialTheme.colorScheme.surfaceContainer, + level = LiquidGlassSurfaceLevel.Panel + ) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text("查询条件", style = MaterialTheme.typography.titleLarge) + Text( + text = filterSummary( + selectedCampusId, + campusOptions.firstOrNull { it.id == selectedCampusId }?.name, + selectedBuildingIds.size, + selectedUnits.size, + startDate, + endDate + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium ) } + AppHeaderIconButton( + imageVector = if (filtersExpanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, + contentDescription = if (filtersExpanded) "收起查询条件" else "展开查询条件", + onClick = { filtersExpanded = !filtersExpanded } + ) } - } - FilterCard(title = "选择教学楼") { - when { - selectedCampusId == null -> { - Text( - text = "请先选择校区", - style = MaterialTheme.typography.bodyMedium, - color = 50.n1 withNight 80.n1 + AnimatedVisibility( + visible = filtersExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { + AppSelectField( + label = "校区", + selected = selectedCampusId, + options = campusOptions.map { campus -> + AppSelectOption(campus.id, campus.name) + }, + onSelected = freeClassroomViewModel::selectCampus, + placeholder = "请选择校区", + enabled = !isSearching ) - } - isLoadingBuildings -> { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = 90.a1 + AppSelectField( + label = "教学楼", + selected = selectedBuildingIds.singleOrNull(), + options = buildList> { + add(AppSelectOption(null, "全部教学楼")) + buildings.forEach { building -> + add(AppSelectOption(building.id, building.nameZh)) + } + }, + onSelected = freeClassroomViewModel::selectBuilding, + placeholder = when { + isLoadingBuildings -> "正在加载教学楼" + buildings.isEmpty() -> "当前校区暂无教学楼" + else -> "全部教学楼" + }, + enabled = !isSearching && !isLoadingBuildings && buildings.isNotEmpty() ) - } - buildings.isEmpty() -> { - Text( - text = "当前校区暂无教学楼", - style = MaterialTheme.typography.bodyMedium, - color = 50.n1 withNight 80.n1 - ) - } + FilterGroup(label = "时段") { + UnitGrid( + selectedUnits = selectedUnits, + onSelectAll = freeClassroomViewModel::selectAllUnits, + onToggleUnit = freeClassroomViewModel::toggleUnit + ) + } - else -> { - HorizontalChipRow { - items(buildings) { building -> - FilterChip( - text = building.nameZh, - selected = building.id in selectedBuildingIds, - onClick = { freeClassroomViewModel.toggleBuilding(building.id) }, - isSingle = false - ) + FilterGroup(label = "日期") { + ChipRow { + item { + SelectionChip( + text = "今天", + selected = startDate == LocalDate.now() && endDate == startDate, + onClick = { + freeClassroomViewModel.setDateRange(LocalDate.now(), LocalDate.now()) + } + ) + } + item { + val tomorrow = LocalDate.now().plusDays(1) + SelectionChip( + text = "明天", + selected = startDate == tomorrow && endDate == tomorrow, + onClick = { freeClassroomViewModel.setDateRange(tomorrow, tomorrow) } + ) + } + item { + SelectionChip( + text = "${startDate.monthValue}/${startDate.dayOfMonth} 起", + selected = true, + onClick = { showStartDatePicker = true } + ) + } + item { + SelectionChip( + text = "${endDate.monthValue}/${endDate.dayOfMonth} 止", + selected = true, + onClick = { showEndDatePicker = true } + ) + } } } } } - } - FilterCard( - title = "选择节次", - trailingHeader = { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - ShortcutChip( - text = "上午", - selected = (1..5).all { it in selectedUnits }, - onClick = { freeClassroomViewModel.toggleUnitsRange(1, 5) } - ) - ShortcutChip( - text = "下午", - selected = (6..10).all { it in selectedUnits }, - onClick = { freeClassroomViewModel.toggleUnitsRange(6, 10) } - ) - ShortcutChip( - text = "晚上", - selected = (11..13).all { it in selectedUnits }, - onClick = { freeClassroomViewModel.toggleUnitsRange(11, 13) } - ) - } - } - ) { - HorizontalChipRow { - items((1..13).toList()) { unit -> - FilterChip( - text = "${unit}节", - selected = unit in selectedUnits, - onClick = { freeClassroomViewModel.toggleUnit(unit) }, - isSingle = false - ) - } - } - Text( - text = "未选择节次时,默认按 1-13 节查询", - style = MaterialTheme.typography.bodySmall, - color = 50.n1 withNight 80.n1 - ) - } - - FilterCard( - title = "选择日期", - trailingHeader = { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - ShortcutChip( - text = "今天", - selected = startDate == LocalDate.now() && endDate == LocalDate.now(), - onClick = { freeClassroomViewModel.setDateRange(LocalDate.now(), LocalDate.now()) } - ) - ShortcutChip( - text = "明天", - selected = startDate == LocalDate.now().plusDays(1) && endDate == LocalDate.now().plusDays(1), - onClick = { freeClassroomViewModel.setDateRange(LocalDate.now().plusDays(1), LocalDate.now().plusDays(1)) } - ) - } - } - ) { - if (showStartDatePicker) { - MyDatePickerDialog( - initialDate = startDate, - minDate = LocalDate.now(), - onDateSelected = { - freeClassroomViewModel.setStartDate(it) - showStartDatePicker = false - }, - onDismiss = { showStartDatePicker = false } - ) - } - - if (showEndDatePicker) { - MyDatePickerDialog( - initialDate = endDate, - minDate = startDate, - onDateSelected = { - freeClassroomViewModel.setEndDate(it) - showEndDatePicker = false - }, - onDismiss = { showEndDatePicker = false } - ) - } - - HorizontalChipRow { - item { - FilterChip( - text = "开始: " + startDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), - selected = true, - onClick = { showStartDatePicker = true }, - isSingle = true - ) - } - item { - FilterChip( - text = "结束: " + endDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), - selected = true, - onClick = { showEndDatePicker = true }, - isSingle = true - ) + AppButton( + onClick = { + freeClassroomViewModel.searchFreeRooms() + filtersExpanded = false + }, + modifier = Modifier.fillMaxWidth(), + enabled = selectedCampusId != null && !isSearching + ) { + if (isSearching) { + AppCircularProgressIndicator(size = 20.dp, strokeWidth = 2.dp) + Spacer(Modifier.size(10.dp)) } + Text(if (isSearching) "正在查询" else "查询空闲教室") } } - Spacer(modifier = Modifier.height(24.dp)) - } } - Row( - modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = if (isSearching) "查询中..." else "开始查询空闲教室", - modifier = Modifier - .weight(1f) - .clip(ContinuousCapsule) - .background(if (selectedCampusId != null) 90.a1 else 70.n1 withNight 30.n1) - .clickable(enabled = selectedCampusId != null && !isSearching) { + errorMessage?.let { message -> + item { + MessageCard( + title = "查询失败", + message = message, + actionLabel = "重试", + onAction = { + freeClassroomViewModel.clearError() freeClassroomViewModel.searchFreeRooms() - isFilterCollapsed = true } - .padding(16.dp, 10.dp), - color = if (selectedCampusId != null) 0.n1 else 60.n1 withNight 60.n1, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ) - - IconButton( - onClick = { isFilterCollapsed = !isFilterCollapsed }, - modifier = Modifier - .clip(ContinuousCapsule) - .background(95.n1 withNight 25.n1) - ) { - Icon( - imageVector = if (isFilterCollapsed) Icons.Default.KeyboardArrowDown else Icons.Default.KeyboardArrowUp, - contentDescription = if (isFilterCollapsed) "展开筛选条件" else "收起筛选条件", - tint = 10.n1 withNight 90.n1 ) } } - Spacer(modifier = Modifier.height(32.dp)) - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .clip(SmoothRoundedCornerShape(32.dp)) - .background(100.n1 withNight 20.n1) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { + + item { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "查询结果", - style = MaterialTheme.typography.titleMedium - ) - Text( - text = "共 ${rooms.size} 间", - style = MaterialTheme.typography.bodyMedium, - color = 40.n1 withNight 80.n1 - ) + Text("查询结果", style = MaterialTheme.typography.titleLarge) + if (hasSearched && !isSearching) { + Text( + text = "${rooms.size} 间", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium + ) + } } - if (isSearching) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = 90.a1 - ) - } else if (rooms.isEmpty()) { - Text( - text = "暂无数据,请先设置条件后查询", - style = MaterialTheme.typography.bodyMedium + } + + when { + isSearching -> item { MessageCard("正在查找", "正在获取符合条件的教室…") } + !hasSearched -> item { + MessageCard("选择条件后查询", "默认会查询今天、全部教学楼和全天时段。") + } + rooms.isEmpty() && errorMessage == null -> item { + MessageCard( + title = "没有找到空闲教室", + message = "可以扩大日期或时段范围后再试。", + actionLabel = "调整条件", + onAction = { filtersExpanded = true } ) - } else { - rooms.forEach { room -> - Column( - modifier = Modifier - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(20.dp)) - .background(95.n1 withNight 25.n1) - .padding(14.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - Text(text = room.nameZh, style = MaterialTheme.typography.titleMedium) - Text( - text = "${room.building.nameZh} ${room.floor}层 ${room.remark ?: ""}", - style = MaterialTheme.typography.bodyMedium, - color = 40.n1 withNight 80.n1 - ) - } - } + } + else -> items( + items = rooms, + key = { room -> "${room.id}-${room.building.id}" } + ) { room -> + FreeRoomCard(room) } } } } @Composable -private fun HorizontalChipRow( - content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit -) { +private fun FilterGroup(label: String, content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(label, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + content() + } +} + +@Composable +private fun ChipRow(content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit) { LazyRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -407,70 +353,143 @@ private fun HorizontalChipRow( } @Composable -private fun FilterCard( - title: String, - trailingHeader: (@Composable () -> Unit)? = null, - content: @Composable () -> Unit, +private fun UnitGrid( + selectedUnits: Set, + onSelectAll: () -> Unit, + onToggleUnit: (Int) -> Unit ) { - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .clip(SmoothRoundedCornerShape(32.dp)) - .background(100.n1 withNight 20.n1) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = title, style = MaterialTheme.typography.titleMedium) - trailingHeader?.invoke() + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + (0..13).chunked(5).forEach { rowChoices -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + rowChoices.forEach { unit -> + SelectionChip( + text = if (unit == 0) "全天" else unit.toString(), + selected = if (unit == 0) selectedUnits.isEmpty() else unit in selectedUnits, + onClick = if (unit == 0) onSelectAll else ({ onToggleUnit(unit) }), + modifier = Modifier.weight(1f), + centered = true + ) + } + repeat(5 - rowChoices.size) { Spacer(modifier = Modifier.weight(1f)) } + } } - content() } } @Composable -private fun FilterChip( +private fun SelectionChip( text: String, selected: Boolean, onClick: () -> Unit, - isSingle: Boolean + modifier: Modifier = Modifier, + centered: Boolean = false ) { + AppFilterChip( + selected = selected, + onClick = onClick, + modifier = modifier, + label = { + Box( + modifier = if (centered) Modifier.fillMaxWidth() else Modifier, + contentAlignment = Alignment.Center + ) { + Text( + text = text, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + style = MaterialTheme.typography.labelLarge + ) + } + } + ) +} + +@Composable +private fun SupportingText(text: String) { Text( text = text, - modifier = Modifier - .clip(ContinuousCapsule) - .background( - when { - selected -> 90.a1 - isSingle -> 95.n1 withNight 25.n1 - else -> 95.n1 withNight 30.n1 - } - ) - .clickable { onClick() } - .padding(14.dp, 8.dp), - color = if (selected) 0.n1 else Color.Unspecified, + color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium ) } @Composable -private fun ShortcutChip( - text: String, - selected: Boolean, - onClick: () -> Unit +private fun FreeRoomCard(room: FreeRoom) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(20.dp), + fallbackColor = MaterialTheme.colorScheme.surfaceContainer, + level = LiquidGlassSurfaceLevel.Panel + ) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text(room.nameZh, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + text = buildString { + append(room.building.nameZh) + append(" · ${room.floor} 层") + room.remark?.takeIf(String::isNotBlank)?.let { append(" · $it") } + }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Composable +private fun MessageCard( + title: String, + message: String, + actionLabel: String? = null, + onAction: (() -> Unit)? = null ) { - Text( - text = text, + Column( modifier = Modifier - .clip(ContinuousCapsule) - .background(if (selected) 80.a1 else 95.n1 withNight 30.n1) - .clickable { onClick() } - .padding(14.dp, 8.dp), - color = if (selected) 0.n1 else Color.Unspecified, - style = MaterialTheme.typography.bodySmall - ) + .padding(horizontal = 16.dp) + .fillMaxWidth() + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(20.dp), + fallbackColor = MaterialTheme.colorScheme.surfaceContainerLow, + level = LiquidGlassSurfaceLevel.Panel + ) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium) + if (actionLabel != null && onAction != null) { + AppButton(onClick = onAction, variant = AppButtonVariant.Secondary) { + if (actionLabel == "重试") { + Icon(Icons.Rounded.Refresh, contentDescription = null) + Spacer(Modifier.size(8.dp)) + } + Text(actionLabel) + } + } + } +} + +private fun filterSummary( + selectedCampusId: Int?, + campusName: String?, + selectedBuildingCount: Int, + selectedUnitCount: Int, + startDate: LocalDate, + endDate: LocalDate +): String { + if (selectedCampusId == null) return "请选择校区" + val building = if (selectedBuildingCount == 0) "全部教学楼" else "$selectedBuildingCount 栋教学楼" + val units = if (selectedUnitCount == 0) "全天" else "$selectedUnitCount 个节次" + val formatter = DateTimeFormatter.ofPattern("M月d日") + val date = if (startDate == endDate) startDate.format(formatter) + else "${startDate.format(formatter)}–${endDate.format(formatter)}" + return "${campusName.orEmpty()} · $building · $units · $date" } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/FreeClassroomDatePicker.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/FreeClassroomDatePicker.kt index 91917784..0f02a027 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/FreeClassroomDatePicker.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/FreeClassroomDatePicker.kt @@ -10,6 +10,11 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.withNight @@ -35,7 +40,7 @@ fun MyDatePickerDialog( ) val colors = DatePickerDefaults.colors( - containerColor = 100.n1 withNight 20.n1, + containerColor = Color.Transparent, titleContentColor = 10.n1 withNight 90.n1, headlineContentColor = 10.n1 withNight 90.n1, weekdayContentColor = 40.n1 withNight 60.n1, @@ -57,6 +62,12 @@ fun MyDatePickerDialog( DatePickerDialog( onDismissRequest = onDismiss, + modifier = Modifier.appLiquidGlassSurface( + shape = DatePickerDefaults.shape, + fallbackColor = 100.n1 withNight 20.n1, + level = LiquidGlassSurfaceLevel.Floating, + backdropSamplingEnabled = false + ), confirmButton = { TextButton( onClick = { @@ -82,6 +93,8 @@ fun MyDatePickerDialog( Text("取消") } }, + shape = DatePickerDefaults.shape, + tonalElevation = 0.dp, colors = colors ) { DatePicker( diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Grade.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Grade.kt index 2acf0597..e7f9ae91 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Grade.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Grade.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -32,22 +33,39 @@ import com.ahu.ahutong.data.GradeEvaluationGate import com.ahu.ahutong.data.crawler.model.jwxt.CourseGrade import com.ahu.ahutong.data.dao.AHUCache import com.ahu.ahutong.data.mock.MockScenarioController +import com.ahu.ahutong.data.model.AppUiTheme import com.ahu.ahutong.data.model.Grade import com.ahu.ahutong.data.model.GradeStudentProfile +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.components.AppFilterChip +import com.ahu.ahutong.ui.components.AppHeaderIconButton +import com.ahu.ahutong.ui.components.AppScrollablePageLayout +import com.ahu.ahutong.ui.components.AppSearchField +import com.ahu.ahutong.ui.components.AppSelectField +import com.ahu.ahutong.ui.components.AppSelectOption +import com.ahu.ahutong.ui.components.AppCard +import com.ahu.ahutong.ui.components.LocalAppUiTheme import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.GradeViewModel +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.capsule.ContinuousCapsule import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.withNight import com.ahu.ahutong.personalization.ui.rememberBehaviorActionReporter import com.ahu.ahutong.personalization.action.AppActionId +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.icons.useful.Cancel +import top.yukonga.miuix.kmp.icon.icons.useful.Refresh +import top.yukonga.miuix.kmp.icon.icons.useful.Search @OptIn(ExperimentalMaterial3Api::class) @Composable fun Grade( gradeViewModel: GradeViewModel = hiltViewModel(), - onNavigateToEvaluation: () -> Unit = {} + onNavigateToEvaluation: () -> Unit = {}, + onBack: (() -> Unit)? = null ) { DisposableEffect(gradeViewModel) { onDispose { gradeViewModel.onPresetSurfaceDisposed() } @@ -62,7 +80,6 @@ fun Grade( var searchExpanded by rememberSaveable { mutableStateOf(false) } var searchQuery by rememberSaveable { mutableStateOf("") } - var termMenuExpanded by rememberSaveable { mutableStateOf(false) } BackHandler(enabled = searchExpanded) { searchExpanded = false @@ -123,92 +140,43 @@ fun Grade( } .orEmpty() - Box( + AppScrollablePageLayout( + title = stringResource(id = R.string.grade), + onBack = onBack, + scrollState = scrollState, modifier = Modifier .fillMaxSize() - .systemBarsPadding() - ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(bottom = 96.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp, 32.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(id = R.string.grade), - color = 0.n1 withNight 100.n1, - style = MaterialTheme.typography.headlineMedium - ) - - Row( - modifier = Modifier - .clip(ContinuousCapsule) - .background(100.n1 withNight 30.n1) - ) { - IconButton( - onClick = { - behaviorReporter.organic(AppActionId.MANUAL_REFRESH_GRADE) - gradeViewModel.refreshGrade() - } - ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = "刷新成绩", - tint = 0.n1 withNight 100.n1 - ) - } - - IconButton( - onClick = { - searchExpanded = !searchExpanded - if (!searchExpanded) searchQuery = "" - } - ) { - Icon( - imageVector = if (searchExpanded) - Icons.Default.Close - else - Icons.Default.Search, - contentDescription = null, - tint = 0.n1 withNight 100.n1 - ) - } - } + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), + bottomPadding = 48.dp, + actions = { + AppHeaderIconButton( + imageVector = Icons.Default.Refresh, + miuixImageVector = MiuixIcons.Useful.Refresh, + contentDescription = "刷新成绩", + onClick = { + behaviorReporter.organic(AppActionId.MANUAL_REFRESH_GRADE) + gradeViewModel.refreshGrade() } - - if (searchExpanded) { - OutlinedTextField( - value = searchQuery, - onValueChange = { searchQuery = it }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = ContinuousCapsule, - colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = 0.n1 withNight 100.n1, - unfocusedTextColor = 0.n1 withNight 100.n1, - cursorColor = 90.a1 withNight 90.a1, - ), - placeholder = { - Text( - text = "搜索课程", - color = 50.n1 withNight 70.n1 - ) - } - ) + ) + AppHeaderIconButton( + imageVector = if (searchExpanded) Icons.Default.Close else Icons.Default.Search, + miuixImageVector = if (searchExpanded) MiuixIcons.Useful.Cancel else MiuixIcons.Useful.Search, + contentDescription = if (searchExpanded) "关闭搜索" else "搜索成绩", + onClick = { + searchExpanded = !searchExpanded + if (!searchExpanded) searchQuery = "" } - } + ) + } + ) { + if (searchExpanded) { + AppSearchField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + placeholder = "搜索课程" + ) + } // Profile selector - shown when student has multiple profiles (micro-major/minor) if (!searchExpanded && gradeViewModel.studentProfiles.size > 1) { @@ -220,22 +188,15 @@ fun Grade( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { gradeViewModel.studentProfiles.forEachIndexed { index, profile -> - FilterChip( + AppFilterChip( selected = gradeViewModel.selectedProfileIndex == index, onClick = { gradeViewModel.selectProfile(index) }, label = { Text( text = profile.displayName, - style = MaterialTheme.typography.labelMedium + style = MaterialTheme.typography.labelLarge ) - }, - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = 80.a1 withNight 50.a1, - selectedLabelColor = 100.n1 withNight 0.n1, - containerColor = 90.n1 withNight 20.n1, - labelColor = 10.n1 withNight 90.n1 - ), - shape = ContinuousCapsule + } ) } } @@ -243,22 +204,6 @@ fun Grade( // 改成学期下拉选择(替代原来的学年+学期双筛选) if (!searchExpanded) { - gradeViewModel.presetCandidates.firstOrNull()?.let { candidate -> - LaunchedEffect(candidate.opportunityId, candidate.presetId) { - gradeViewModel.onPresetCandidateVisible(candidate) - } - Text( - text = "使用常用条件", - modifier = Modifier - .padding(horizontal = 16.dp) - .clip(ContinuousCapsule) - .background(90.a1) - .clickable { gradeViewModel.applyPresetCandidate(candidate) } - .padding(horizontal = 16.dp, vertical = 10.dp), - color = 0.n1, - style = MaterialTheme.typography.titleMedium - ) - } val allTerms = gradeViewModel.grade?.termGradeList ?.sortedWith( compareByDescending { @@ -269,69 +214,28 @@ fun Grade( } ) .orEmpty() - val selectedTermText = - "${gradeViewModel.schoolYear} 第${gradeViewModel.schoolTerm}学期" - - ExposedDropdownMenuBox( - expanded = termMenuExpanded, - onExpandedChange = { - termMenuExpanded = !termMenuExpanded + AppSelectField( + label = "选择学期", + selected = gradeViewModel.schoolYear?.let { schoolYear -> + gradeViewModel.schoolTerm?.let { schoolTerm -> schoolYear to schoolTerm } }, - modifier = Modifier.padding(horizontal = 16.dp) - ) { - OutlinedTextField( - value = selectedTermText, - onValueChange = {}, - readOnly = true, - modifier = Modifier - .menuAnchor() - .fillMaxWidth(), - shape = ContinuousCapsule, - label = { Text("选择学期") }, - colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = 10.n1 withNight 90.n1, - unfocusedTextColor = 10.n1 withNight 90.n1, - focusedLabelColor = 40.a1 withNight 80.a1, - unfocusedLabelColor = 50.n1 withNight 70.n1, - focusedBorderColor = 40.a1 withNight 80.a1, - unfocusedBorderColor = 70.n1 withNight 50.n1, - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - cursorColor = 40.a1 withNight 80.a1 - ), - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon( - expanded = termMenuExpanded - ) - } - ) - - ExposedDropdownMenu( - expanded = termMenuExpanded, - onDismissRequest = { - termMenuExpanded = false - }, - modifier = Modifier.background(99.n1 withNight 10.n1) - ) { - allTerms.forEach { term -> - DropdownMenuItem( - text = { - Text( - text = "${term.schoolYear} 第${term.term}学期", - color = 10.n1 withNight 90.n1 - ) - }, - colors = MenuDefaults.itemColors( - textColor = 10.n1 withNight 90.n1 - ), - onClick = { - gradeViewModel.selectTerm(term.schoolYear, term.term) - termMenuExpanded = false - } - ) - } - } - } + options = allTerms.map { term -> + AppSelectOption( + value = term.schoolYear.orEmpty() to term.term.orEmpty(), + label = "${term.schoolYear} 第${term.term}学期" + ) + }, + onSelected = { (schoolYear, schoolTerm) -> + gradeViewModel.selectTerm(schoolYear, schoolTerm) + }, + modifier = Modifier.padding(horizontal = 16.dp), + valueTextAlign = if (LocalAppUiTheme.current == AppUiTheme.MATERIAL) { + TextAlign.Start + } else { + TextAlign.End + }, + miuixStandalone = true + ) } if (!searchExpanded) { @@ -425,7 +329,6 @@ fun Grade( color = 50.n1 withNight 70.n1 ) } - } } } @@ -439,17 +342,16 @@ private fun GradeCard( val gradeText = item.grade.stripHtml() val gradeDetail = item.gradeDetail.stripHtml() - Column( + AppCard( modifier = Modifier - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(4.dp)) - .background(100.n1 withNight 20.n1) - .padding(24.dp, 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + .fillMaxWidth(), + shape = SmoothRoundedCornerShape(20.dp), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp) ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = item.course ?: "", - color = 0.n1 withNight 100.n1, + color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium ) @@ -469,31 +371,35 @@ private fun GradeCard( } append(" 绩点: ${item.gradePoint} 学分: ${item.credit}") }, - modifier = Modifier.clickable(onClick = onNavigateToEvaluation), - color = 30.n1 withNight 90.n1, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clickable(onClick = onNavigateToEvaluation), + color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyLarge ) } else { Text( text = "成绩: $gradeText 绩点: ${item.gradePoint} 学分: ${item.credit}", - color = 30.n1 withNight 90.n1, + color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyLarge ) } Text( text = "${item.courseNature ?: ""} (${item.courseNum ?: ""})", - color = 50.n1 withNight 80.n1, + color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium ) if (!needsEvaluation && !gradeDetail.isNullOrBlank()) { Text( text = gradeDetail, - color = 40.a1 withNight 80.a1, + color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall ) } + } } } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Home.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Home.kt index 795e6484..f1f78f77 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Home.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Home.kt @@ -1,9 +1,5 @@ package com.ahu.ahutong.ui.screen.main -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.AnimatedVisibilityScope -import androidx.compose.animation.fadeIn -import androidx.compose.animation.slideInVertically import androidx.activity.compose.BackHandler import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown @@ -34,6 +30,7 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -56,9 +53,11 @@ import com.ahu.ahutong.data.dao.AHUCache import com.ahu.ahutong.data.schedule.CurrentWeekResolver import androidx.navigation.NavHostController import com.ahu.ahutong.data.debug.DebugClock +import com.ahu.ahutong.data.model.ScheduleConfigBean import com.ahu.ahutong.data.mock.MockScenarioController import com.ahu.ahutong.personalization.runtime.BehaviorPredictionRuntime import com.ahu.ahutong.personalization.semantic.MutationId +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground import com.ahu.ahutong.ui.screen.main.home.AtAGlance import com.ahu.ahutong.ui.screen.main.home.HomeWeatherWidget import com.ahu.ahutong.ui.screen.main.home.HomeWidgetDragOverlay @@ -70,7 +69,14 @@ import com.ahu.ahutong.ui.state.DiscoveryViewModel import com.ahu.ahutong.ui.state.ScheduleViewModel import com.ahu.ahutong.ui.state.WeatherHomeConfig import com.ahu.ahutong.ui.state.WeatherHomeMode +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import com.kyant.monet.n1 +import com.kyant.monet.withNight +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale import kotlin.math.hypot import kotlin.math.roundToInt @@ -92,6 +98,7 @@ fun Home( scheduleViewModel: ScheduleViewModel = viewModel(), navController: NavHostController, behaviorRuntime: BehaviorPredictionRuntime, + onOpenSchedule: () -> Unit = { navController.navigate("schedule") }, homeEditEnabled: Boolean = false, enterEditModeRequest: Boolean = false, onEnterEditModeRequestConsumed: () -> Unit = {} @@ -99,35 +106,53 @@ fun Home( val density = LocalDensity.current val schedule = scheduleViewModel.schedule.observeAsState().value?.getOrNull() ?: emptyList() val scheduleConfig by scheduleViewModel.scheduleConfig.observeAsState() - val effectiveScheduleConfig = scheduleConfig ?: CurrentWeekResolver.resolveLocalConfig()?.config + val localScheduleConfig by produceState( + initialValue = null, + key1 = scheduleConfig + ) { + value = scheduleConfig ?: withContext(Dispatchers.IO) { + CurrentWeekResolver.resolveLocalConfig()?.config + } + } + val effectiveScheduleConfig = scheduleConfig ?: localScheduleConfig val isInSemester = effectiveScheduleConfig?.isInSemester != false val currentWeek = effectiveScheduleConfig?.week ?: 1 val mockRefreshRevision by MockScenarioController.refreshRevisions().collectAsState() - val todayCourses = if (isInSemester) { - schedule - .filter { effectiveScheduleConfig?.week in it.startWeek..it.endWeek } - .filter { it.weekday == (effectiveScheduleConfig?.weekDay ?: 1) } - .filter { - if (currentWeek in it.weekIndexes) { - true - } else { - currentWeek % 2 == it.startWeek % 2 + val todayCourses = remember(schedule, effectiveScheduleConfig, isInSemester, currentWeek) { + if (isInSemester) { + schedule + .asSequence() + .filter { effectiveScheduleConfig?.week in it.startWeek..it.endWeek } + .filter { it.weekday == (effectiveScheduleConfig?.weekDay ?: 1) } + .filter { + if (currentWeek in it.weekIndexes) { + true + } else { + currentWeek % 2 == it.startWeek % 2 + } } - } - .sortedBy { it.startTime } - } else { - emptyList() + .sortedBy { it.startTime } + .toList() + } else { + emptyList() + } + } + val initialCalendar = remember { Calendar.getInstance(Locale.CHINA) } + var currentDateText by remember { mutableStateOf("") } + var currentMinutes by remember { + mutableIntStateOf( + initialCalendar.get(Calendar.HOUR_OF_DAY) * 60 + initialCalendar.get(Calendar.MINUTE) + ) } - var currentMinutes by remember { mutableIntStateOf(DebugClock.currentMinutes()) } var isEditingHome by remember { mutableStateOf(false) } var homeWidgetSlots by remember { - mutableStateOf(normalizeHomeWidgetSlots(AHUCache.getHomeWidgetSlots())) + mutableStateOf(normalizeHomeWidgetSlots(listOf("bathroom", "electricity"))) } val slotBounds = remember { mutableStateMapOf() } var libraryBounds by remember { mutableStateOf(null) } var rootTopLeft by remember { mutableStateOf(Offset.Zero) } var activeDrag by remember { mutableStateOf(null) } - val dropSlopPx = with(density) { 48.dp.toPx() } + val dropSlopPx = remember(density) { with(density) { 48.dp.toPx() } } val highlightedSlot = activeDrag?.let { findHomeWidgetDropSlot( drag = it, @@ -136,7 +161,12 @@ fun Home( dropSlopPx = dropSlopPx ) } - val weatherHomeConfig = WeatherHomeConfig.fromCache() + val weatherHomeConfig by produceState( + initialValue = WeatherHomeConfig(), + key1 = Unit + ) { + value = withContext(Dispatchers.IO) { WeatherHomeConfig.fromCache() } + } fun saveHomeWidgetSlots(slots: List) { val normalizedSlots = normalizeHomeWidgetSlots(slots) @@ -254,16 +284,15 @@ fun Home( exitHomeEditMode() } + LaunchedEffect(Unit) { + homeWidgetSlots = withContext(Dispatchers.IO) { + normalizeHomeWidgetSlots(AHUCache.getHomeWidgetSlots()) + } + } LaunchedEffect(Unit) { if (!enterEditModeRequest) { exitHomeEditMode() } - discoveryViewModel.loadActivityBean() - - repeat(2 - discoveryViewModel.visibilities.size) { - delay(100) - discoveryViewModel.visibilities += discoveryViewModel.visibilities.lastIndex + 1 - } } LaunchedEffect(enterEditModeRequest) { if (enterEditModeRequest) { @@ -286,8 +315,13 @@ fun Home( } LaunchedEffect(Unit) { while (true) { + val now = withContext(Dispatchers.IO) { DebugClock.nowDate() } + val calendar = Calendar.getInstance(Locale.CHINA).apply { time = now } + currentDateText = withContext(Dispatchers.Default) { + SimpleDateFormat("MM-dd / EE", Locale.CHINA).format(now) + } + currentMinutes = calendar.get(Calendar.HOUR_OF_DAY) * 60 + calendar.get(Calendar.MINUTE) delay(HOME_REFRESH_INTERVAL_MS) - currentMinutes = DebugClock.currentMinutes() discoveryViewModel.refreshCardBalance() } } @@ -299,6 +333,7 @@ fun Home( Box( modifier = Modifier .fillMaxSize() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1) .onGloballyPositioned { rootTopLeft = it.boundsInRoot().topLeft } .pointerInput(isEditingHome, homeEditEnabled) { if (isEditingHome) { @@ -350,7 +385,8 @@ fun Home( AtAGlance( todayCourses = todayCourses, currentMinutes = currentMinutes, - navController = navController, + currentDateText = currentDateText, + onOpenSchedule = onOpenSchedule, isInSemester = isInSemester, enabled = !isEditingHome, trailingContent = { @@ -372,17 +408,15 @@ fun Home( } ) if (todayCourses.isNotEmpty()) { - SlideInContent(visible = 0 in discoveryViewModel.visibilities) { - TodayCourseList( - todayCourses = todayCourses, - currentMinutes = currentMinutes, - navController = navController, - enabled = !isEditingHome - ) - } + TodayCourseList( + todayCourses = todayCourses, + currentMinutes = currentMinutes, + onOpenSchedule = onOpenSchedule, + enabled = !isEditingHome + ) } if (weatherHomeConfig.showOnHome && weatherHomeConfig.mode == WeatherHomeMode.Detailed) { - SlideInContent(visible = !isEditingHome) { + if (!isEditingHome) { HomeWeatherWidget( onClick = { navController.navigate("weather") }, config = weatherHomeConfig, @@ -390,32 +424,30 @@ fun Home( ) } } - SlideInContent(visible = 1 in discoveryViewModel.visibilities) { - HomeWidgetSlotLayout( - balance = discoveryViewModel.balance, - transitionBalance = discoveryViewModel.transitionBalance, - onRefreshBalance = discoveryViewModel::refreshCardBalance, - navController = navController, - slots = homeWidgetSlots, - isEditing = isEditingHome, - highlightedSlot = highlightedSlot, - draggingWidgetId = activeDrag?.widgetId, - onEnterEdit = ::enterHomeEditMode, - onHomeWidgetClick = ::removeHomeWidget, - onSlotPositioned = { slotIndex, bounds -> - slotBounds[slotIndex] = bounds - }, - onHomeWidgetDragStarted = { widgetId, slotIndex, bounds -> - startDrag(widgetId, slotIndex, bounds) - }, - onHomeWidgetDragged = { dragAmount -> - activeDrag = activeDrag?.let { - it.copy(topLeft = it.topLeft + dragAmount) - } - }, - onHomeWidgetDragStopped = ::stopDrag - ) - } + HomeWidgetSlotLayout( + balance = discoveryViewModel.balance, + transitionBalance = discoveryViewModel.transitionBalance, + onRefreshBalance = discoveryViewModel::refreshCardBalance, + navController = navController, + slots = homeWidgetSlots, + isEditing = isEditingHome, + highlightedSlot = highlightedSlot, + draggingWidgetId = activeDrag?.widgetId, + onEnterEdit = ::enterHomeEditMode, + onHomeWidgetClick = ::removeHomeWidget, + onSlotPositioned = { slotIndex, bounds -> + slotBounds[slotIndex] = bounds + }, + onHomeWidgetDragStarted = { widgetId, slotIndex, bounds -> + startDrag(widgetId, slotIndex, bounds) + }, + onHomeWidgetDragged = { dragAmount -> + activeDrag = activeDrag?.let { + it.copy(topLeft = it.topLeft + dragAmount) + } + }, + onHomeWidgetDragStopped = ::stopDrag + ) } val placedWidgetIds = homeWidgetSlots.filterNotNull().toSet() @@ -545,17 +577,3 @@ private fun Rect.expandedBy(padding: Float): Rect { private fun Rect.centerDistanceTo(point: Offset): Float { return hypot(center.x - point.x, center.y - point.y) } - -@Composable -fun SlideInContent( - visible: Boolean, - modifier: Modifier = Modifier, - content: @Composable AnimatedVisibilityScope.() -> Unit -) { - AnimatedVisibility( - visible = visible, - modifier = modifier, - enter = fadeIn() + slideInVertically { it / 2 }, - content = content - ) -} diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/LostFound.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/LostFound.kt index 5e500951..99d29672 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/LostFound.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/LostFound.kt @@ -1,6 +1,7 @@ package com.ahu.ahutong.ui.screen.main import android.widget.Toast +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -36,17 +37,35 @@ import coil.compose.AsyncImage import com.ahu.ahutong.data.dao.AHUCache import com.ahu.ahutong.data.mock.MockScenarioController import com.ahu.ahutong.data.crawler.model.adwnh.LostFoundItem +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.components.AppButton +import com.ahu.ahutong.ui.components.AppButtonVariant +import com.ahu.ahutong.ui.components.AppCircularProgressIndicator +import com.ahu.ahutong.ui.components.AppFloatingActionButton +import com.ahu.ahutong.ui.components.AppHeaderIconButton +import com.ahu.ahutong.ui.components.AppLazyPageLayout +import com.ahu.ahutong.ui.components.AppModalBottomSheet +import com.ahu.ahutong.ui.components.AppSearchField +import com.ahu.ahutong.ui.components.AppSelectField +import com.ahu.ahutong.ui.components.AppSelectOption +import com.ahu.ahutong.ui.components.AppTextField import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.LostFoundViewModel -import com.kyant.capsule.ContinuousCapsule +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.withNight import kotlinx.coroutines.flow.distinctUntilChanged +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.icons.useful.Cancel +import top.yukonga.miuix.kmp.icon.icons.useful.Refresh +import top.yukonga.miuix.kmp.icon.icons.useful.Search @OptIn(ExperimentalMaterial3Api::class) @Composable fun LostFound( + onBack: (() -> Unit)? = null, lostFoundViewModel: LostFoundViewModel = hiltViewModel() ) { DisposableEffect(lostFoundViewModel) { @@ -245,16 +264,37 @@ fun LostFound( Box( modifier = Modifier .fillMaxSize() - .systemBarsPadding() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1) ) { - LazyColumn( + AppLazyPageLayout( + title = "失物招领", + onBack = onBack, state = listState, modifier = Modifier.fillMaxSize(), - verticalArrangement = - Arrangement.spacedBy(24.dp), - contentPadding = - PaddingValues(bottom = 96.dp) + verticalArrangement = Arrangement.spacedBy(24.dp), + bottomPadding = 96.dp, + actions = { + AppHeaderIconButton( + imageVector = Icons.Default.Refresh, + miuixImageVector = MiuixIcons.Useful.Refresh, + contentDescription = "刷新失物招领", + onClick = lostFoundViewModel::refreshList + ) + AppHeaderIconButton( + imageVector = if (searchExpanded) Icons.Default.Close else Icons.Default.Search, + miuixImageVector = if (searchExpanded) { + MiuixIcons.Useful.Cancel + } else { + MiuixIcons.Useful.Search + }, + contentDescription = if (searchExpanded) "关闭搜索" else "搜索", + onClick = { + searchExpanded = !searchExpanded + if (!searchExpanded) searchQuery = "" + } + ) + } ) { item { @@ -265,147 +305,12 @@ fun LostFound( verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - - /** - * 左边 1/3 - */ - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.CenterStart - ) { - FilterChip( - selected = - lostFoundViewModel.currentState == 1, - onClick = { - lostFoundViewModel.switchState(1) - }, - label = { - Text( - text = "失物招领", - fontSize = 18.sp, - maxLines = 1 - ) - } - ) - } - - /** - * 中间 1/3 - */ - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.Center - ) { - FilterChip( - selected = - lostFoundViewModel.currentState == 2, - onClick = { - lostFoundViewModel.switchState(2) - }, - label = { - Text( - text = "寻物启事", - fontSize = 18.sp, - maxLines = 1 - ) - } - ) - } - - /** - * 右边 1/3(容器三等分,按钮不拉伸) - */ - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.CenterEnd - ) { - Row( - modifier = Modifier - .clip(ContinuousCapsule) - .background( - 100.n1 withNight 30.n1 - ), - horizontalArrangement = - Arrangement.spacedBy(4.dp), - verticalAlignment = - Alignment.CenterVertically - ) { - IconButton( - onClick = { - lostFoundViewModel.refreshList() - - Toast.makeText( - context, - "刷新成功", - Toast.LENGTH_SHORT - ).show() - } - ) { - Icon( - imageVector = - Icons.Default.Refresh, - contentDescription = null - ) - } - - IconButton( - onClick = { - searchExpanded = - !searchExpanded - - if (!searchExpanded) { - searchQuery = "" - } - } - ) { - Icon( - imageVector = - if (searchExpanded) - Icons.Default.Close - else - Icons.Default.Search, - contentDescription = null - ) - } - } - } - } if (searchExpanded) { - OutlinedTextField( + AppSearchField( value = searchQuery, - onValueChange = { - searchQuery = it - }, + onValueChange = { searchQuery = it }, modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = ContinuousCapsule, - colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = 0.n1 withNight 100.n1, - unfocusedTextColor = 0.n1 withNight 100.n1, - cursorColor = 90.a1 withNight 90.a1, - ), - placeholder = { - Text("搜索全部信息") - } - ) - } - lostFoundViewModel.presetCandidates.firstOrNull()?.let { candidate -> - LaunchedEffect(candidate.opportunityId, candidate.presetId) { - lostFoundViewModel.onPresetCandidateVisible(candidate) - } - Text( - text = "使用常用条件", - modifier = Modifier - .clip(ContinuousCapsule) - .background(90.a1) - .clickable { lostFoundViewModel.applyPresetCandidate(candidate) } - .padding(horizontal = 16.dp, vertical = 10.dp), - color = 0.n1, - style = MaterialTheme.typography.titleMedium + placeholder = "搜索全部信息" ) } } @@ -413,150 +318,42 @@ fun LostFound( if (!searchExpanded) { item { - Row( + Column( modifier = Modifier - .padding( - horizontal = 16.dp - ) - .clip( - ContinuousCapsule - ) - .background( - 100.n1 withNight 20.n1 - ) - .padding(8.dp), - verticalAlignment = - Alignment.CenterVertically + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - FilterChip( - selected = - lostFoundViewModel.selectedCampus == null, - onClick = { - lostFoundViewModel.selectCampusFilter(null) - }, - label = { - Text("全部校区") - } - ) - - Spacer( - modifier = - Modifier.width(8.dp) + AppSelectField( + label = "信息类别", + selected = lostFoundViewModel.currentState, + options = listOf( + AppSelectOption(1, "失物招领"), + AppSelectOption(2, "寻物启事") + ), + onSelected = lostFoundViewModel::switchState, + miuixStandalone = true ) - - LazyRow( - horizontalArrangement = - Arrangement.spacedBy( - 8.dp - ) - ) { - items(allCampus) { campus -> - val selected = - lostFoundViewModel.selectedCampus == campus.id - - Text( - text = - campus.campusName, - modifier = - Modifier - .clip( - ContinuousCapsule - ) - .background( - if (selected) - 90.a1 - else - Color.Unspecified - ) - .clickable { - lostFoundViewModel.selectCampusFilter(campus.id) - } - .padding( - 16.dp, - 8.dp - ), - color = - if (selected) - 0.n1 - else - Color.Unspecified - ) - } - } - } - } - - item { - Row( - modifier = Modifier - .padding( - horizontal = 16.dp - ) - .clip( - ContinuousCapsule - ) - .background( - 100.n1 withNight 20.n1 - ) - .padding(8.dp), - verticalAlignment = - Alignment.CenterVertically - ) { - FilterChip( - selected = - lostFoundViewModel.selectedType == null, - onClick = { - lostFoundViewModel.selectTypeFilter(null) - }, - label = { - Text("全部类型") - } + AppSelectField( + label = "校区", + selected = lostFoundViewModel.selectedCampus, + options = listOf(AppSelectOption(null, "全部校区")) + + allCampus.map { campus -> + AppSelectOption(campus.id, campus.campusName) + }, + onSelected = lostFoundViewModel::selectCampusFilter, + miuixStandalone = true ) - - Spacer( - modifier = - Modifier.width(8.dp) + AppSelectField( + label = "物品类型", + selected = lostFoundViewModel.selectedType, + options = listOf(AppSelectOption(null, "全部类型")) + + allLostFoundType.map { type -> + AppSelectOption(type.typeId, type.typeName) + }, + onSelected = lostFoundViewModel::selectTypeFilter, + miuixStandalone = true ) - - LazyRow( - horizontalArrangement = - Arrangement.spacedBy( - 8.dp - ) - ) { - items(allLostFoundType) { type -> - val selected = - lostFoundViewModel.selectedType == type.typeId - - Text( - text = - type.typeName, - modifier = - Modifier - .clip( - ContinuousCapsule - ) - .background( - if (selected) - 90.a1 - else - Color.Unspecified - ) - .clickable { - lostFoundViewModel.selectTypeFilter(type.typeId) - } - .padding( - 16.dp, - 8.dp - ), - color = - if (selected) - 0.n1 - else - Color.Unspecified - ) - } - } } } } @@ -587,6 +384,7 @@ fun LostFound( TextButton( onClick = { showMyPostSheet = true + lostFoundViewModel.loadMyPosts() } ) { Text("管理我的帖子") @@ -594,20 +392,73 @@ fun LostFound( } } - items(filteredList) { item -> + if (lostFoundViewModel.listLoading && lostFoundList.isEmpty()) { + item { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center + ) { AppCircularProgressIndicator() } + } + } + + lostFoundViewModel.errorMessage?.let { message -> + item { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(20.dp), + fallbackColor = MaterialTheme.colorScheme.errorContainer, + level = LiquidGlassSurfaceLevel.Panel + ) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("加载失败", style = MaterialTheme.typography.titleMedium) + Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant) + AppButton( + onClick = { lostFoundViewModel.fetchFirstPage() }, + variant = AppButtonVariant.Secondary + ) { Text("重试") } + } + } + } + + if (!lostFoundViewModel.listLoading && filteredList.isEmpty() && lostFoundViewModel.errorMessage == null) { + item { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(20.dp), + fallbackColor = MaterialTheme.colorScheme.surfaceContainerLow, + level = LiquidGlassSurfaceLevel.Panel + ) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text("暂无匹配内容", style = MaterialTheme.typography.titleMedium) + Text( + "尝试切换校区、类型或清空搜索关键词。", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + items(filteredList, key = { it.id }) { item -> Column( modifier = Modifier .padding( horizontal = 16.dp ) .fillMaxWidth() - .clip( - SmoothRoundedCornerShape( - 4.dp - ) - ) - .background( - 100.n1 withNight 20.n1 + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(20.dp), + fallbackColor = MaterialTheme.colorScheme.surfaceContainer, + level = LiquidGlassSurfaceLevel.Panel ) .clickable { selectedItem = item @@ -708,33 +559,46 @@ fun LostFound( contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + AppCircularProgressIndicator() } } } } - FloatingActionButton( + AppFloatingActionButton( onClick = { showPublishSheet = true }, modifier = Modifier .align(Alignment.BottomEnd) .padding(24.dp) - .size(64.dp) ) { - Text( - text = "+", - fontSize = 28.sp - ) + val addColor = LocalContentColor.current + Canvas(modifier = Modifier.size(22.dp)) { + val strokeWidth = 2.5.dp.toPx() + drawLine( + color = addColor, + start = androidx.compose.ui.geometry.Offset(size.width / 2f, 0f), + end = androidx.compose.ui.geometry.Offset(size.width / 2f, size.height), + strokeWidth = strokeWidth, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + drawLine( + color = addColor, + start = androidx.compose.ui.geometry.Offset(0f, size.height / 2f), + end = androidx.compose.ui.geometry.Offset(size.width, size.height / 2f), + strokeWidth = strokeWidth, + cap = androidx.compose.ui.graphics.StrokeCap.Round + ) + } } selectedItem?.let { item -> - ModalBottomSheet( + AppModalBottomSheet( + title = item.title ?: "无标题", onDismissRequest = { selectedItem = null } ) { - Column( modifier = Modifier .fillMaxWidth() @@ -742,15 +606,6 @@ fun LostFound( verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text( - text = item.title ?: "无标题", - style = - MaterialTheme.typography - .headlineSmall, - fontWeight = - FontWeight.Bold - ) - Text( "联系人:${item.linkman ?: "未知"}" ) @@ -893,12 +748,8 @@ fun LostFound( } if (showMyPostSheet) { - val myPosts = lostFoundList.filter { - it.pubuser?.idNumber == - lostFoundViewModel.currentUserName - } - - ModalBottomSheet( + AppModalBottomSheet( + title = "管理我的帖子", onDismissRequest = { showMyPostSheet = false } @@ -908,25 +759,36 @@ fun LostFound( .fillMaxWidth() .padding(24.dp) ) { - Text( - text = "管理我的帖子", - style = - MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - - Spacer( - modifier = Modifier.height(16.dp) - ) - - if (myPosts.isEmpty()) { - Text("暂无帖子") - } else { + when { + lostFoundViewModel.myPostsLoading -> Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center + ) { + AppCircularProgressIndicator(size = 24.dp, strokeWidth = 2.5.dp) + } + lostFoundViewModel.myPostsError != null -> Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = lostFoundViewModel.myPostsError.orEmpty(), + color = MaterialTheme.colorScheme.error + ) + AppButton( + onClick = lostFoundViewModel::loadMyPosts, + modifier = Modifier.fillMaxWidth(), + variant = AppButtonVariant.Secondary + ) { Text("重试") } + } + lostFoundViewModel.myPosts.isEmpty() -> Text("暂无帖子") + else -> { LazyColumn( verticalArrangement = Arrangement.spacedBy(12.dp) ) { - items(myPosts) { item -> + items( + items = lostFoundViewModel.myPosts, + key = LostFoundItem::id + ) { item -> Card( modifier = Modifier.fillMaxWidth() @@ -961,26 +823,28 @@ fun LostFound( TextButton( onClick = { - item.id?.let { id -> - lostFoundViewModel - .deleteLostFound( - id - ) - + lostFoundViewModel.deleteLostFound(item.id) { result -> Toast.makeText( context, - "删除成功", + if (result.isSuccess) "删除成功" else + result.exceptionOrNull()?.message ?: "删除失败", Toast.LENGTH_SHORT ).show() } - } + }, + enabled = item.id !in lostFoundViewModel.deletingPostIds ) { - Text("删除") + if (item.id in lostFoundViewModel.deletingPostIds) { + AppCircularProgressIndicator(size = 16.dp, strokeWidth = 2.dp) + } else { + Text("删除") + } } } } } } + } } } } @@ -1018,7 +882,8 @@ fun LostFound( mutableStateOf("1") } - ModalBottomSheet( + AppModalBottomSheet( + title = "发布帖子", onDismissRequest = { showPublishSheet = false } @@ -1030,6 +895,7 @@ fun LostFound( .imePadding() .padding(24.dp) .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) ){ Text( text = "*目前智慧安大图片功能有时无法使用,请大家文字描述尽量详尽", @@ -1037,142 +903,68 @@ fun LostFound( color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), fontSize = 14.sp ) - Text( - text = "发布帖子", - style = - MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(4.dp)) - - OutlinedTextField( + AppTextField( value = linkman, - onValueChange = { - linkman = it - }, + onValueChange = { linkman = it }, modifier = Modifier.fillMaxWidth(), - label = { - Text("联系人 *") - } + label = "联系人 *" ) - OutlinedTextField( + AppTextField( value = phone, - onValueChange = { - phone = it - }, + onValueChange = { phone = it }, modifier = Modifier.fillMaxWidth(), - label = { - Text("联系电话 *") - } + label = "联系电话 *" ) - OutlinedTextField( + AppTextField( value = title, - onValueChange = { - title = it - }, + onValueChange = { title = it }, modifier = Modifier.fillMaxWidth(), - label = { - Text("描述内容 *") - } + label = "描述内容 *" ) - OutlinedTextField( + AppTextField( value = num1, - onValueChange = { - num1 = it - }, + onValueChange = { num1 = it }, modifier = Modifier.fillMaxWidth(), - label = { - Text("证件号(可选)") - } + label = "证件号(可选)" ) - Spacer(modifier = Modifier.height(12.dp)) - - Text( - "选择校区", - style = MaterialTheme.typography.titleSmall + AppSelectField( + label = "校区 *", + selected = publishCampusId, + options = allCampus.map { campus -> + AppSelectOption(campus.id, campus.campusName) + }, + onSelected = { publishCampusId = it }, + placeholder = "请选择校区", + miuixStandalone = true ) - LazyRow( - horizontalArrangement = - Arrangement.spacedBy(8.dp) - ) { - items(allCampus) { campus -> - FilterChip( - selected = - publishCampusId == campus.id, - onClick = { - publishCampusId = campus.id - }, - label = { - Text(campus.campusName) - } - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - "选择类型", - style = MaterialTheme.typography.titleSmall + AppSelectField( + label = "物品类型 *", + selected = publishTypeId, + options = allLostFoundType.map { type -> + AppSelectOption(type.typeId, type.typeName) + }, + onSelected = { publishTypeId = it }, + placeholder = "请选择物品类型", + miuixStandalone = true ) - LazyRow( - horizontalArrangement = - Arrangement.spacedBy(8.dp) - ) { - items(allLostFoundType) { type -> - FilterChip( - selected = - publishTypeId == type.typeId, - onClick = { - publishTypeId = type.typeId - }, - label = { - Text(type.typeName) - } - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - "选择事件类型", - style = MaterialTheme.typography.titleSmall + AppSelectField( + label = "信息类别 *", + selected = publishState, + options = listOf( + AppSelectOption("1", "失物招领"), + AppSelectOption("2", "寻物启事") + ), + onSelected = { publishState = it }, + miuixStandalone = true ) - Row( - horizontalArrangement = - Arrangement.spacedBy(8.dp) - ) { - FilterChip( - selected = publishState == "1", - onClick = { - publishState = "1" - }, - label = { - Text("失物招领") - } - ) - - FilterChip( - selected = publishState == "2", - onClick = { - publishState = "2" - }, - label = { - Text("寻物启事") - } - ) - } - - Button( + AppButton( onClick = { if ( @@ -1188,7 +980,7 @@ fun LostFound( Toast.LENGTH_SHORT ).show() - return@Button + return@AppButton } lostFoundViewModel.publishLostFound( @@ -1199,19 +991,24 @@ fun LostFound( campusId = publishCampusId!!, typeId = publishTypeId!!, state = publishState - ) - - showPublishSheet = false - - Toast.makeText( - context, - "发布成功", - Toast.LENGTH_SHORT - ).show() + ) { result -> + if (result.isSuccess) showPublishSheet = false + Toast.makeText( + context, + if (result.isSuccess) "发布成功" else + result.exceptionOrNull()?.message ?: "发布失败", + Toast.LENGTH_SHORT + ).show() + } }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + enabled = !lostFoundViewModel.isPublishing ) { - Text("发布") + if (lostFoundViewModel.isPublishing) { + AppCircularProgressIndicator(size = 18.dp, strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + } + Text(if (lostFoundViewModel.isPublishing) "正在发布" else "发布") } Spacer( diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/NetworkRecharge.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/NetworkRecharge.kt index a53d9911..f191f348 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/NetworkRecharge.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/NetworkRecharge.kt @@ -1,11 +1,5 @@ package com.ahu.ahutong.ui.screen.main -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -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.Column @@ -13,27 +7,16 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -43,23 +26,26 @@ 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.graphics.Color import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.ahu.ahutong.data.crawler.PayState +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.components.AppButton +import com.ahu.ahutong.ui.components.AppButtonVariant +import com.ahu.ahutong.ui.components.AppCircularProgressIndicator +import com.ahu.ahutong.ui.components.AppFilterChip +import com.ahu.ahutong.ui.components.AppScrollablePageLayout +import com.ahu.ahutong.ui.components.AppTextField import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.ahu.ahutong.ui.component.SecurePaymentPasswordDialog import com.ahu.ahutong.ui.state.NetworkRechargePageState import com.ahu.ahutong.ui.state.NetworkRechargeUiData import com.ahu.ahutong.ui.state.NetworkRechargeViewModel -import com.kyant.monet.a1 +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.n1 import com.kyant.monet.withNight import com.ahu.ahutong.personalization.ui.rememberBehaviorActionReporter @@ -68,6 +54,7 @@ import kotlinx.coroutines.delay @Composable fun NetworkRecharge( + onBack: () -> Unit, viewModel: NetworkRechargeViewModel = viewModel() ) { val behaviorReporter = rememberBehaviorActionReporter() @@ -87,8 +74,12 @@ fun NetworkRecharge( LaunchedEffect(payState) { when (payState) { - is PayState.Succeeded, is PayState.Failed -> { - delay(1200) + is PayState.Succeeded -> { + delay(1_000L) + viewModel.load() + } + is PayState.Failed -> { + delay(PAYMENT_RESULT_DISPLAY_DURATION_MS) viewModel.resetPayState() } @@ -96,19 +87,13 @@ fun NetworkRecharge( } } - Column( + AppScrollablePageLayout( + title = "网费充值", + onBack = onBack, modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) - .systemBarsPadding(), - verticalArrangement = Arrangement.spacedBy(24.dp) + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1) ) { - Text( - text = "网费充值", - modifier = Modifier.padding(24.dp, 32.dp), - style = MaterialTheme.typography.headlineMedium - ) - when (val state = pageState) { NetworkRechargePageState.Loading -> { LoadingCard() @@ -172,85 +157,51 @@ fun NetworkRecharge( } if (showDialog) { - AlertDialog( - containerColor = 100.n1 withNight 20.n1, - titleContentColor = 10.n1 withNight 90.n1, - textContentColor = 10.n1 withNight 90.n1, - onDismissRequest = { showDialog = false }, - title = { Text("请输入校园卡密码") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = password, - onValueChange = { input -> - if (input.length <= 6 && input.all { it.isDigit() }) { - password = input - passwordError = null - } - }, - label = { Text("密码 (6位数字)", color = 40.n1 withNight 60.n1) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), - visualTransformation = PasswordVisualTransformation(), - isError = passwordError != null, - colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = 10.n1 withNight 90.n1, - unfocusedTextColor = 10.n1 withNight 90.n1, - focusedBorderColor = 20.n1 withNight 80.n1 - ) - ) - passwordError?.let { - Text( - text = it, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall - ) - } - } + SecurePaymentPasswordDialog( + password = password, + onPasswordChange = { + password = it + passwordError = null }, - confirmButton = { - TextButton( - onClick = { - if (password.length == 6) { - showDialog = false - behaviorReporter.organic(AppActionId.SUBMIT_NETWORK_RECHARGE) - viewModel.pay(amount, password) - password = "" - passwordError = null - } else { - passwordError = "密码必须是6位数字" - } - } - ) { - Text("确认", color = 10.n1 withNight 90.n1) - } + title = "请输入校园卡密码", + errorMessage = passwordError, + onDismissRequest = { + showDialog = false + password = "" + passwordError = null }, - dismissButton = { - TextButton( - onClick = { - showDialog = false - password = "" - passwordError = null - } - ) { - Text("取消", color = 10.n1 withNight 90.n1) + onConfirm = { confirmedPassword -> + if (confirmedPassword.length == 6) { + showDialog = false + behaviorReporter.organic(AppActionId.SUBMIT_NETWORK_RECHARGE) + viewModel.pay(amount, confirmedPassword) + password = "" + passwordError = null + } else { + passwordError = "密码必须是6位数字" } } ) } } +private const val PAYMENT_RESULT_DISPLAY_DURATION_MS = 3_000L + @Composable private fun LoadingCard() { Box( modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth() - .clip(SmoothRoundedCornerShape(24.dp)) - .background(100.n1 withNight 20.n1) + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(24.dp), + fallbackColor = 100.n1 withNight 20.n1, + level = LiquidGlassSurfaceLevel.Panel + ) .padding(24.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator(color = 30.n1 withNight 70.n1) + AppCircularProgressIndicator() } } @@ -263,8 +214,11 @@ private fun ErrorCard( modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth() - .clip(SmoothRoundedCornerShape(24.dp)) - .background(100.n1 withNight 20.n1) + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(24.dp), + fallbackColor = 100.n1 withNight 20.n1, + level = LiquidGlassSurfaceLevel.Panel + ) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -273,12 +227,9 @@ private fun ErrorCard( color = 10.n1 withNight 90.n1, style = MaterialTheme.typography.bodyLarge ) - Text( - text = "重试", - modifier = Modifier.clickable(onClick = onRetry), - color = 30.n1 withNight 70.n1, - style = MaterialTheme.typography.titleMedium - ) + AppButton(onClick = onRetry, variant = AppButtonVariant.Secondary) { + Text("重试") + } } } @@ -290,8 +241,11 @@ private fun NetworkAccountCard( modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth() - .clip(SmoothRoundedCornerShape(24.dp)) - .background(100.n1 withNight 20.n1) + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(24.dp), + fallbackColor = 100.n1 withNight 20.n1, + level = LiquidGlassSurfaceLevel.Panel + ) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { @@ -347,8 +301,11 @@ private fun AmountCard( modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth() - .clip(SmoothRoundedCornerShape(24.dp)) - .background(100.n1 withNight 20.n1) + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(24.dp), + fallbackColor = 100.n1 withNight 20.n1, + level = LiquidGlassSurfaceLevel.Panel + ) ) { Text( text = "充值金额", @@ -365,37 +322,22 @@ private fun AmountCard( verticalArrangement = Arrangement.spacedBy(12.dp) ) { quickAmounts.forEach { quickAmount -> - Text( - text = quickAmount, - modifier = Modifier - .clip(SmoothRoundedCornerShape(16.dp)) - .background(90.a1 withNight 30.n1) - .clickable { onQuickAmountClick(quickAmount) } - .padding(horizontal = 12.dp, vertical = 8.dp), - color = 10.n1 withNight 90.n1, - style = MaterialTheme.typography.bodyMedium + AppFilterChip( + selected = amount == normalizeQuickAmount(quickAmount), + onClick = { onQuickAmountClick(quickAmount) }, + label = { Text(quickAmount) } ) } } } - TextField( + AppTextField( value = amount, onValueChange = onAmountChange, - modifier = Modifier.fillMaxWidth(), - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ), - placeholder = { - Text( - text = if (maxAmount.isNullOrBlank()) "请输入金额" else "请输入金额,单次最高 $maxAmount 元", - color = 30.n1 withNight 70.n1 - ) - }, - textStyle = TextStyle(fontSize = 16.sp, color = 10.n1 withNight 90.n1), + label = if (maxAmount.isNullOrBlank()) "金额(元)" else "金额(最高 $maxAmount 元)", + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done @@ -420,102 +362,57 @@ private fun RechargeActionRow( payState: PayState, onConfirm: () -> Unit ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Box( - modifier = Modifier - .navigationBarsPadding() - .padding(16.dp) - .clip(SmoothRoundedCornerShape(32.dp)) - .background( - animateColorAsState( - targetValue = when (payState) { - PayState.Idle -> 90.a1 withNight 85.a1 - PayState.InProgress -> 70.a1 withNight 60.a1 - is PayState.Failed -> Color.Red - is PayState.Succeeded -> 70.a1 withNight 60.a1 - } - ).value - ) - .animateContentSize(spring(stiffness = Spring.StiffnessLow)) - ) { - when (payState) { - PayState.Idle -> { - Text( - text = "确认", - modifier = Modifier - .clickable(onClick = onConfirm) - .padding(24.dp, 16.dp), - color = 0.n1, - style = MaterialTheme.typography.titleMedium - ) - } - - PayState.InProgress -> { - Row( - modifier = Modifier.padding(24.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = 100.n1, - strokeWidth = 4.dp - ) - Text( - text = "支付中", - color = 100.n1, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium - ) - } - } - - is PayState.Failed -> { - Row( - modifier = Modifier.padding(24.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = 100.n1 - ) - Text( - text = "充值失败:${payState.message}", - color = 100.n1, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium - ) - } - } - - is PayState.Succeeded -> { - Row( - modifier = Modifier.padding(24.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = 100.n1 - ) - Text( - text = "充值成功!订单号:${payState.message}", - color = 100.n1, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium - ) - } - } + when (payState) { + PayState.Idle -> Unit + PayState.InProgress -> Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + AppCircularProgressIndicator(size = 22.dp, strokeWidth = 3.dp) + Text(" 正在充值", style = MaterialTheme.typography.bodyLarge) } + is PayState.Failed -> StatusMessage( + icon = Icons.Default.Close, + message = "充值失败:${payState.message}", + isError = true + ) + is PayState.Succeeded -> StatusMessage( + icon = Icons.Default.Check, + message = "充值成功,正在刷新账户信息", + isError = false + ) } + AppButton( + onClick = onConfirm, + modifier = Modifier.fillMaxWidth(), + enabled = payState is PayState.Idle + ) { + Text(if (payState is PayState.InProgress) "正在充值" else "确认充值") + } + } +} + +@Composable +private fun StatusMessage( + icon: androidx.compose.ui.graphics.vector.ImageVector, + message: String, + isError: Boolean +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp), tint = color) + Text(message, color = color, style = MaterialTheme.typography.bodyMedium) } } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/PhoneBook.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/PhoneBook.kt index 87def764..1f51b227 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/PhoneBook.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/PhoneBook.kt @@ -51,16 +51,29 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.ahu.ahutong.R import com.ahu.ahutong.data.model.Tel +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.AppSearchHeader +import com.ahu.ahutong.ui.components.AppHeaderIconButton +import com.ahu.ahutong.ui.components.AppLazyPageLayout +import com.ahu.ahutong.ui.components.AppSearchField +import com.ahu.ahutong.ui.components.AppSelectField +import com.ahu.ahutong.ui.components.AppSelectOption +import com.ahu.ahutong.ui.components.AppCard +import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.TelDirectoryViewModel +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.capsule.ContinuousCapsule import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.withNight +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.icons.useful.Cancel +import top.yukonga.miuix.kmp.icon.icons.useful.Search @OptIn(ExperimentalMaterial3Api::class) @Composable -fun PhoneBook() { +fun PhoneBook(onBack: (() -> Unit)? = null) { val context = LocalContext.current var dialData by rememberSaveable { mutableStateOf(null) } var selectedCategory by rememberSaveable { mutableStateOf("师生综合服务大厅") } @@ -86,146 +99,79 @@ fun PhoneBook() { } } - Column( + AppLazyPageLayout( + title = stringResource(id = R.string.phone_book), + onBack = onBack, modifier = Modifier .fillMaxSize() - .systemBarsPadding() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), + bottomPadding = 48.dp, + actions = { + AppHeaderIconButton( + imageVector = if (isSearchActive) Icons.Default.Close else Icons.Default.Search, + miuixImageVector = if (isSearchActive) MiuixIcons.Useful.Cancel else MiuixIcons.Useful.Search, + contentDescription = if (isSearchActive) "关闭搜索" else "搜索", + onClick = { + isSearchActive = !isSearchActive + if (!isSearchActive) searchQuery = "" + } + ) + } ) { if (isSearchActive) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp, 24.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = { - isSearchActive = false - searchQuery = "" - }) { - Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = "Back" - ) - } - TextField( + item(key = "search") { + AppSearchField( value = searchQuery, onValueChange = { searchQuery = it }, modifier = Modifier - .weight(1f) - .padding(horizontal = 8.dp), - placeholder = { Text("搜索电话或部门") }, - singleLine = true, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - focusedTextColor = 0.n1 withNight 100.n1, - unfocusedTextColor = 0.n1 withNight 100.n1, - cursorColor = 90.a1 withNight 90.a1, - ), - trailingIcon = if (searchQuery.isNotEmpty()) { - { - IconButton(onClick = { searchQuery = "" }) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Clear" - ) - } - } - } else null + .fillMaxWidth() + .padding(horizontal = 16.dp), + placeholder = "搜索电话或部门" ) } - } else { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp, 32.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(id = R.string.phone_book), - style = MaterialTheme.typography.headlineMedium - ) - Row { - IconButton(onClick = { isSearchActive = true }) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = null - ) - } + if (searchResults.isEmpty() && searchQuery.isNotEmpty()) { + item(key = "empty") { + Text( + text = "未找到相关结果", + modifier = Modifier.fillMaxWidth().padding(24.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } - } - } - - if (isSearchActive) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (searchResults.isEmpty() && searchQuery.isNotEmpty()) { - item { - Text( - text = "未找到相关结果", - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } else { - items(searchResults) { tel -> - TelItem( - tel = tel, - onItemClick = { - if (it.tel != null && it.tel2 != null && it.tel != it.tel2) { - dialData = it - } else { - context.startActivity( - Intent( - Intent.ACTION_DIAL, - Uri.parse("tel:0551-${it.tel ?: it.tel2}") - ) - ) - } - } - ) - } + } else items( + items = searchResults, + key = { tel -> "${tel.name}-${tel.tel}-${tel.tel2}" } + ) { tel -> + Box(modifier = Modifier.padding(horizontal = 16.dp)) { + TelItem(tel = tel, onItemClick = { selected -> + openTelOrChooseCampus(context, selected) { dialData = selected } + }) } } } else { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - Categories( - selectedCategory = selectedCategory, - onCategorySelected = { selectedCategory = it } - ) - Telephones( - selectedCategory = selectedCategory, - onItemClick = { - if (it.tel != null && it.tel2 != null && it.tel != it.tel2) { - dialData = it - } else { - context.startActivity( - Intent( - Intent.ACTION_DIAL, - Uri.parse("tel:0551-${it.tel ?: it.tel2}") - ) - ) - } - } + item(key = "category") { + AppSelectField( + label = "部门分类", + selected = selectedCategory, + options = TelDirectoryViewModel.TelBook.keys.map { category -> + AppSelectOption(category, category) + }, + onSelected = { selectedCategory = it }, + modifier = Modifier.padding(horizontal = 16.dp), + miuixStandalone = true ) } + items( + items = TelDirectoryViewModel.TelBook.getValue(selectedCategory), + key = { tel -> "${selectedCategory}-${tel.name}-${tel.tel}-${tel.tel2}" } + ) { tel -> + Box(modifier = Modifier.padding(horizontal = 16.dp)) { + TelItem(tel = tel, onItemClick = { selected -> + openTelOrChooseCampus(context, selected) { dialData = selected } + }) + } + } } } DialDialog( @@ -234,20 +180,30 @@ fun PhoneBook() { ) } +private fun openTelOrChooseCampus( + context: android.content.Context, + tel: Tel, + onChooseCampus: () -> Unit +) { + if (tel.tel != null && tel.tel2 != null && tel.tel != tel.tel2) { + onChooseCampus() + } else { + context.startActivity(Intent(Intent.ACTION_DIAL, Uri.parse("tel:0551-${tel.tel ?: tel.tel2}"))) + } +} + @Composable private fun TelItem( tel: Tel, onItemClick: (Tel) -> Unit ) { - Column( + AppCard( modifier = Modifier - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(4.dp)) - .background(100.n1 withNight 20.n1) - .clickable { onItemClick(tel) } - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + .fillMaxWidth(), + shape = SmoothRoundedCornerShape(20.dp), + onClick = { onItemClick(tel) } ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = tel.name, style = MaterialTheme.typography.titleMedium @@ -275,6 +231,7 @@ private fun TelItem( } } } + } } } @@ -286,8 +243,11 @@ private fun Categories( LazyRow( modifier = Modifier .padding(horizontal = 16.dp) - .clip(ContinuousCapsule) - .background(100.n1 withNight 20.n1), + .appLiquidGlassSurface( + shape = ContinuousCapsule, + fallbackColor = 100.n1 withNight 20.n1, + level = LiquidGlassSurfaceLevel.Panel + ), contentPadding = PaddingValues(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -390,11 +350,16 @@ private fun DialDialog( ) { val context = LocalContext.current if (tel != null) { + val dialogShape = SmoothRoundedCornerShape(32.dp) Dialog(onDismissRequest = onDismiss) { Column( modifier = Modifier - .clip(SmoothRoundedCornerShape(32.dp)) - .background(96.n1 withNight 10.n1) + .appLiquidGlassSurface( + shape = dialogShape, + fallbackColor = 96.n1 withNight 10.n1, + level = LiquidGlassSurfaceLevel.Floating, + backdropSamplingEnabled = false + ) ) { Column( modifier = Modifier.padding(24.dp), @@ -402,7 +367,7 @@ private fun DialDialog( ) { Text( text = "请选择校区", - color = 0.n1 withNight 100.n1, + color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.headlineMedium ) } @@ -410,7 +375,7 @@ private fun DialDialog( modifier = Modifier .fillMaxWidth() .height(2.dp) - .background(80.n1 withNight 30.n1) + .background(MaterialTheme.colorScheme.outlineVariant) ) Row( modifier = Modifier @@ -428,14 +393,14 @@ private fun DialDialog( onDismiss() } .padding(24.dp, 16.dp), - color = 0.n1 withNight 100.n1, + color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Center ) Box( modifier = Modifier .width(2.dp) .fillMaxHeight() - .background(80.n1 withNight 30.n1) + .background(MaterialTheme.colorScheme.outlineVariant) ) Text( text = "龙河校区", @@ -448,7 +413,7 @@ private fun DialDialog( onDismiss() } .padding(24.dp, 16.dp), - color = 0.n1 withNight 100.n1, + color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Center ) } 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 8745ed4e..07b8d4ec 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 @@ -15,11 +15,11 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -27,7 +27,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.outlined.Download @@ -35,7 +34,7 @@ import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.TaskAlt import androidx.compose.material.icons.outlined.Tune -import androidx.compose.material3.CircularProgressIndicator +import com.ahu.ahutong.ui.components.AppCircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -59,13 +58,21 @@ 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.activity.ComponentActivity +import androidx.activity.compose.LocalActivity import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import com.ahu.ahutong.data.repository.GitHubContentItem import com.ahu.ahutong.data.repository.RepositoryDirectorySummary import com.ahu.ahutong.data.repository.RepositoryManager +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.components.AppDialogSurface +import com.ahu.ahutong.ui.components.AppHeaderIconButton +import com.ahu.ahutong.ui.components.AppPageLayout import com.ahu.ahutong.ui.state.RepositoryMarkdownUiState import com.ahu.ahutong.ui.state.RepositoryViewModel +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.withNight @@ -78,6 +85,10 @@ import com.ahu.ahutong.personalization.semantic.ContentStateBucket import com.ahu.ahutong.personalization.semantic.ErrorTypeBucket import com.ahu.ahutong.personalization.semantic.ResultCountBucket import com.ahu.ahutong.personalization.semantic.SemanticDomain +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.icons.useful.Refresh +import top.yukonga.miuix.kmp.icon.icons.useful.Save +import top.yukonga.miuix.kmp.icon.icons.useful.Settings @Composable fun Repository( @@ -86,7 +97,7 @@ fun Repository( behaviorRuntime: BehaviorPredictionRuntime ) { val behaviorReporter = rememberBehaviorActionReporter() - val activity = LocalContext.current as androidx.activity.ComponentActivity + val activity = LocalActivity.current as? ComponentActivity ?: return val viewModel: RepositoryViewModel = viewModel(viewModelStoreOwner = activity) val directoryStates by viewModel.directoryStates.collectAsState() val sharedState by viewModel.sharedState.collectAsState() @@ -127,53 +138,39 @@ fun Repository( } } - Column( + AppPageLayout( + title = "学习资料", + onBack = { navController.popBackStack() }, modifier = Modifier .fillMaxSize() - .systemBarsPadding() - .background(96.n1 withNight 10.n1) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = { navController.popBackStack() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "返回" + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), + actions = { + RepositoryRefreshButton( + loading = state.isLoading || state.isRefreshing || sharedState.isCacheWarming, + onRefresh = { + behaviorReporter.organic( + if (state.error == null) AppActionId.MANUAL_REFRESH_REPOSITORY + else AppActionId.RETRY_REPOSITORY + ) + viewModel.refreshDirectory(path) + } ) - } - Text( - text = "学习资料", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.weight(1f) - ) - RepositoryRefreshButton( - loading = state.isLoading || state.isRefreshing || sharedState.isCacheWarming, - onRefresh = { - behaviorReporter.organic( - if (state.error == null) AppActionId.MANUAL_REFRESH_REPOSITORY - else AppActionId.RETRY_REPOSITORY - ) - viewModel.refreshDirectory(path) - } - ) - IconButton(onClick = { navController.navigate("repository_downloads") }) { - Icon( + AppHeaderIconButton( imageVector = Icons.Outlined.Download, + miuixImageVector = MiuixIcons.Useful.Save, contentDescription = "已下载", - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, + onClick = { navController.navigate("repository_downloads") } ) - } - IconButton(onClick = { navController.navigate("repository_settings") }) { - Icon( + AppHeaderIconButton( imageVector = Icons.Outlined.Tune, - contentDescription = "学习资料设置" + miuixImageVector = MiuixIcons.Useful.Settings, + contentDescription = "学习资料设置", + onClick = { navController.navigate("repository_settings") } ) - } } + ) { + Column(modifier = Modifier.fillMaxSize()) { RepositoryBreadcrumb( currentPath = path, @@ -192,20 +189,20 @@ fun Repository( .fillMaxWidth() .padding(horizontal = 20.dp, vertical = 8.dp) .clip(RoundedCornerShape(12.dp)) - .background(Color(0x33FF5252)) + .background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.48f)) .clickable { viewModel.clearError(path) } .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = error, - color = Color(0xFFFF5252), + color = MaterialTheme.colorScheme.onErrorContainer, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f) ) Text( text = "关闭", - color = Color(0xFFFF5252), + color = MaterialTheme.colorScheme.onErrorContainer, style = MaterialTheme.typography.labelMedium ) } @@ -225,7 +222,7 @@ fun Repository( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp) ) { - CircularProgressIndicator(modifier = Modifier.size(28.dp)) + AppCircularProgressIndicator(size = 28.dp) Text( text = "已获取 ${sharedState.cacheWarmUpCount} 个文件", style = MaterialTheme.typography.titleMedium, @@ -290,6 +287,7 @@ fun Repository( } } } + } RepositoryMarkdownReader( markdownState = markdownState, @@ -317,16 +315,14 @@ internal fun RepositoryMarkdownReader( val markwon = remember(context) { Markwon.create(context) } val markdownTextColor = MaterialTheme.colorScheme.onSurface.toArgb() val markdownLinkColor = MaterialTheme.colorScheme.primary.toArgb() - - Dialog( + AppDialogSurface( onDismissRequest = onDismiss, + modifier = Modifier.fillMaxHeight(0.8f), properties = DialogProperties(usePlatformDefaultWidth = false) ) { Column( modifier = Modifier - .fillMaxSize(0.8f) - .clip(RoundedCornerShape(20.dp)) - .background(MaterialTheme.colorScheme.surface) + .fillMaxSize() .padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -357,13 +353,13 @@ internal fun RepositoryMarkdownReader( .height(160.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator(modifier = Modifier.size(28.dp)) + AppCircularProgressIndicator(size = 28.dp) } } markdownState.error != null -> { Text( text = markdownState.error, - color = Color(0xFFFF5252), + color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium ) } @@ -401,22 +397,23 @@ private fun RepositoryRefreshButton( loading: Boolean, onRefresh: () -> Unit ) { - IconButton( - onClick = onRefresh, - enabled = !loading, - modifier = Modifier.size(40.dp) - ) { - if (loading) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), + if (loading) { + Box( + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center + ) { + AppCircularProgressIndicator( + size = 16.dp, strokeWidth = 2.dp ) - } else { - Icon( - imageVector = Icons.Outlined.Refresh, - contentDescription = "刷新" - ) } + } else { + AppHeaderIconButton( + imageVector = Icons.Outlined.Refresh, + miuixImageVector = MiuixIcons.Useful.Refresh, + contentDescription = "刷新", + onClick = onRefresh + ) } } @@ -648,9 +645,9 @@ private fun RepositoryItemRow( ) } isDownloading -> { - CircularProgressIndicator( + AppCircularProgressIndicator( progress = { progress }, - modifier = Modifier.size(24.dp), + size = 24.dp, strokeWidth = 2.5.dp ) } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/RepositoryDownloads.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/RepositoryDownloads.kt index 8f97c58a..9cd8a4db 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/RepositoryDownloads.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/RepositoryDownloads.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckBox import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank import androidx.compose.material.icons.outlined.Delete @@ -50,8 +49,13 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import com.ahu.ahutong.data.repository.DownloadedFile import com.ahu.ahutong.data.repository.RepositoryManager +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.components.AppPageHeader +import com.ahu.ahutong.ui.components.SettingsConfirmationDialog import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.RepositoryViewModel +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.withNight @@ -86,35 +90,22 @@ fun RepositoryDownloads( modifier = Modifier .fillMaxSize() .systemBarsPadding() - .background(96.n1 withNight 10.n1) + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1) ) { - // 顶栏 - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = { navController.popBackStack() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "返回" - ) - } - Text( - text = if (isManaging) "已选择 ${selectedPaths.size} 项" else "已下载文件", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.weight(1f) - ) - if (files.isNotEmpty()) { - TextButton(onClick = { - isManaging = !isManaging - if (!isManaging) selectedPaths = emptySet() - }) { - Text(if (isManaging) "完成" else "管理") + AppPageHeader( + title = if (isManaging) "已选择 ${selectedPaths.size} 项" else "已下载文件", + onBack = { navController.popBackStack() }, + actions = { + if (files.isNotEmpty()) { + TextButton(onClick = { + isManaging = !isManaging + if (!isManaging) selectedPaths = emptySet() + }) { + Text(if (isManaging) "完成" else "管理") + } } } - } + ) if (files.isEmpty()) { Box( @@ -161,8 +152,11 @@ fun RepositoryDownloads( modifier = Modifier .fillMaxWidth() .padding(12.dp) - .clip(SmoothRoundedCornerShape(16.dp)) - .background(100.n1 withNight 30.n1) + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(16.dp), + fallbackColor = 100.n1 withNight 30.n1, + level = LiquidGlassSurfaceLevel.Floating + ) .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween @@ -183,7 +177,7 @@ fun RepositoryDownloads( ) { Text( "删除选中 (${selectedPaths.size})", - color = if (selectedPaths.isNotEmpty()) Color(0xFFFF5252) + color = if (selectedPaths.isNotEmpty()) MaterialTheme.colorScheme.error else secondaryTextColor ) } @@ -235,44 +229,14 @@ private fun ConfirmDialog( onCancel: () -> Unit, onConfirm: () -> Unit ) { - Dialog(onDismissRequest = onCancel) { - Column( - modifier = Modifier - .clip(SmoothRoundedCornerShape(24.dp)) - .background(96.n1 withNight 10.n1) - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = title, - color = 0.n1 withNight 100.n1, - style = MaterialTheme.typography.titleMedium - ) - Text( - text = message, - color = 30.n1 withNight 90.n1, - style = MaterialTheme.typography.bodyMedium - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - Text( - text = "取消", - modifier = Modifier.clickable { onCancel() }.padding(horizontal = 12.dp, vertical = 8.dp), - color = 40.a1 withNight 80.a1, - style = MaterialTheme.typography.labelLarge - ) - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = "删除", - modifier = Modifier.clickable { onConfirm() }.padding(horizontal = 12.dp, vertical = 8.dp), - style = MaterialTheme.typography.labelLarge, - color = Color(0xFFFF5252) - ) - } - } - } + SettingsConfirmationDialog( + title = title, + message = message, + confirmLabel = "删除", + onConfirm = onConfirm, + onDismiss = onCancel, + destructive = true + ) } @Composable @@ -375,7 +339,7 @@ private fun DownloadedFileRow( Icon( imageVector = Icons.Outlined.Delete, contentDescription = "删除", - tint = Color(0xFFFF5252), + tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(22.dp) ) } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/RepositorySettings.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/RepositorySettings.kt index 7b2e71fb..d569c26c 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/RepositorySettings.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/RepositorySettings.kt @@ -16,10 +16,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -35,8 +33,12 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import com.ahu.ahutong.data.repository.RepositoryAccelerationSource import com.ahu.ahutong.data.repository.RepositoryManager +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.components.AppPageHeader import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.PreferencesViewModel +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.withNight @@ -53,26 +55,12 @@ fun RepositorySettings( modifier = Modifier .fillMaxSize() .systemBarsPadding() - .background(96.n1 withNight 10.n1) + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = { navController.popBackStack() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "返回" - ) - } - Text( - text = "学习资料设置", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.weight(1f) - ) - } + AppPageHeader( + title = "学习资料设置", + onBack = { navController.popBackStack() } + ) Column( modifier = Modifier @@ -90,8 +78,11 @@ fun RepositorySettings( Column( modifier = Modifier .fillMaxWidth() - .clip(SmoothRoundedCornerShape(24.dp)) - .background(cardColor), + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(24.dp), + fallbackColor = cardColor, + level = LiquidGlassSurfaceLevel.Panel + ), verticalArrangement = Arrangement.spacedBy(2.dp) ) { RepositoryManager.accelerationSources.forEach { source -> diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Schedule.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Schedule.kt index b11eebcc..1bb73665 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Schedule.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Schedule.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.LaunchedEffect import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.Canvas import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -31,6 +32,7 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -46,14 +48,20 @@ 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.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -61,18 +69,21 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import com.ahu.ahutong.data.model.Course +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.components.AppToggle import com.ahu.ahutong.ui.screen.main.schedule.CourseCard import com.ahu.ahutong.ui.screen.main.schedule.CourseCardSpec import com.ahu.ahutong.ui.screen.main.schedule.CourseDetailDialog +import com.ahu.ahutong.ui.screen.main.schedule.courseTonalPalettes import com.ahu.ahutong.ui.screen.main.schedule.shortScheduleLocation import com.ahu.ahutong.ui.screen.main.schedule.weekRangeText import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.ScheduleViewModel +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.capsule.ContinuousCapsule import com.kyant.monet.Hct.Companion.toHct import com.kyant.monet.LocalTonalPalettes -import com.kyant.monet.PaletteStyle -import com.kyant.monet.TonalPalettes.Companion.toTonalPalettes import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.n2 @@ -80,6 +91,7 @@ import com.kyant.monet.toColor import com.kyant.monet.toSrgb import com.kyant.monet.withNight import kotlinx.coroutines.launch +import kotlinx.coroutines.delay import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale @@ -117,10 +129,18 @@ fun Schedule( var isPreviewNextSemester by rememberSaveable { mutableStateOf(false) } var isOverviewSchedule by rememberSaveable { mutableStateOf(false) } var isSettingsVisible by rememberSaveable { mutableStateOf(false) } + var renderCourseCards by remember { mutableStateOf(false) } val activeScheduleResult = if (isPreviewNextSemester) nextScheduleResult else scheduleResult val schedule = activeScheduleResult?.getOrNull() ?: emptyList() val context = LocalContext.current + LaunchedEffect(schedule, isOverviewSchedule) { + renderCourseCards = false + withFrameNanos { } + delay(48L) + renderCourseCards = true + } + LaunchedEffect(currentWeek) { state.animateScrollToItem( (currentWeek - 3).coerceAtLeast(0) @@ -184,26 +204,48 @@ fun Schedule( } val baseColor = 50.a1.toSrgb().toHct() - val courseColors by remember(schedule) { - mutableStateOf( - schedule.map { it.name }.distinct() - .mapIndexed { index, name -> - name to baseColor.copy( - h = 360.0 * index / schedule.map { it.name } - .distinct().size.coerceAtLeast(1) - ).toSrgb() - .toColor() - }.toMap() + val courseColors = remember(schedule) { + val courseNames = schedule.asSequence().map { it.name }.distinct().toList() + courseNames.mapIndexed { index, name -> + name to baseColor.copy( + h = 360.0 * index / courseNames.size.coerceAtLeast(1) + ).toSrgb().toColor() + }.toMap() + } + val coursesByWeek = remember(schedule) { + List(20) { pageIndex -> + val week = pageIndex + 1 + schedule.filter { week in it.weekIndexes } + } + } + val overviewCourseGroups = remember(schedule) { + schedule + .groupBy { Triple(it.weekday, it.startTime, it.length) } + .values + .toList() + } + val weekDateLabels = remember(scheduleConfig?.startTime) { + val fallbackStart = requireNotNull( + SimpleDateFormat("MM-dd", Locale.CHINA).parse("09-01") ) + val startTime = scheduleConfig?.startTime ?: fallbackStart + val formatter = SimpleDateFormat("MM-dd", Locale.CHINA) + List(20) { pageIndex -> + List(7) { dayIndex -> + Calendar.getInstance().apply { + time = startTime + add(Calendar.DATE, pageIndex * 7 + dayIndex) + }.let { formatter.format(it.time) } + } + } } - val currentWeekCourses = schedule - var detailedCourse by rememberSaveable { mutableStateOf(null) } val settingsCardColor = 100.n1 withNight 20.n1 Column( modifier = Modifier .fillMaxSize() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1) .verticalScroll(rememberScrollState()) .systemBarsPadding() .padding(bottom = 96.dp), @@ -257,7 +299,7 @@ fun Schedule( pagerState.animateScrollToPage(week - 1) } } - .padding(16.dp, 8.dp), + .padding(horizontal = 16.dp, vertical = 12.dp), color = animateColorAsState( targetValue = if (isSelected) { 100.n1 withNight 0.n1 @@ -274,13 +316,16 @@ fun Schedule( // actions Row( modifier = Modifier - .clip(ContinuousCapsule) - .background(100.n1 withNight 30.n1) + .appLiquidGlassSurface( + shape = ContinuousCapsule, + fallbackColor = 100.n1 withNight 30.n1, + level = LiquidGlassSurfaceLevel.Floating + ) .padding(horizontal = 2.dp, vertical = 2.dp) ) { IconButton( - modifier = Modifier.size(38.dp), + modifier = Modifier.size(48.dp), onClick = { if (isPreviewNextSemester) { behaviorRuntime.recordCommittedMutationAsync( @@ -306,7 +351,7 @@ fun Schedule( ) } IconButton( - modifier = Modifier.size(38.dp), + modifier = Modifier.size(48.dp), onClick = { isSettingsVisible = true } ) { Icon( @@ -316,7 +361,7 @@ fun Schedule( ) } IconButton( - modifier = Modifier.size(38.dp), + modifier = Modifier.size(48.dp), onClick = { if (isPreviewNextSemester) { scheduleViewModel.refreshNextSchedule(true) @@ -351,8 +396,10 @@ fun Schedule( Modifier .fillMaxWidth() .height(mainRowHeight + (cellHeight + cellSpacing) * 13 + 24.dp) - .clip(SmoothRoundedCornerShape(32.dp)) - .background(99.n1 withNight 20.n1) + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(32.dp), + fallbackColor = 99.n1 withNight 20.n1 + ) .padding(top = 8.dp) .padding(cellSpacing) } @@ -360,121 +407,51 @@ fun Schedule( // TODO: current time indicator // weekday tags - val weekDates by remember(pageWeek, scheduleConfig?.startTime) { - mutableStateOf( - List(7) { index -> - Calendar.getInstance().apply { - time = scheduleConfig?.startTime - ?: SimpleDateFormat("MM-dd", Locale.CHINA).parse("09-01") - add(Calendar.DATE, ((pageWeek - 1) * 7) + index) - } - } - ) - } - - weekDates.forEachIndexed { index, date -> - val isCurrentWeekday = - !isPreviewNextSemester && - scheduleConfig?.isInSemester == true && - pageWeek == scheduleConfig?.week && - index + 1 == currentWeekday - Column( - modifier = with(CourseCardSpec) { - Modifier - .size(cellWidth, mainRowHeight) - .offset( - x = mainColumnWidth + (cellWidth + cellSpacing) * index + cellSpacing - ) - .clip(SmoothRoundedCornerShape(8.dp)) - .background(if (isCurrentWeekday) 90.a1 else Color.Unspecified) - }, - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = arrayOf( - "周一", - "周二", - "周三", - "周四", - "周五", - "周六", - "周日" - )[index], - color = if (isCurrentWeekday) 0.n1 else Color.Unspecified, - style = MaterialTheme.typography.labelLarge - ) - Text( - text = SimpleDateFormat("MM-dd", Locale.CHINA).format(date.time), - color = if (isCurrentWeekday) 0.n1 else 50.n1 withNight 80.n1, - style = MaterialTheme.typography.labelSmall - ) - } - } + val weekDates = weekDateLabels.getOrElse(page) { emptyList() } - // time tags - ScheduleViewModel.timetable.forEach { (index, time) -> - Column( - modifier = with(CourseCardSpec) { - Modifier - .size(mainColumnWidth, cellHeight) - .offset( - y = mainRowHeight + (cellHeight + cellSpacing) * (index - 1) + cellSpacing - ) - .clip(SmoothRoundedCornerShape(8.dp)) - }, - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = index.toString(), - style = MaterialTheme.typography.labelLarge - ) - Text( - text = time.substringBefore("-"), - color = 50.n1 withNight 80.n1, - style = MaterialTheme.typography.labelSmall - ) - } - } + ScheduleGridLabels( + weekDates = weekDates, + cellWidth = cellWidth, + cellHeight = cellHeight, + pageWeek = pageWeek, + currentWeek = scheduleConfig?.week, + currentWeekday = currentWeekday, + isInSemester = scheduleConfig?.isInSemester == true, + isPreviewNextSemester = isPreviewNextSemester + ) // courses - if (isOverviewSchedule) { - currentWeekCourses - .groupBy { "${it.weekday}-${it.startTime}-${it.length}" } - .values - .forEach { sameTimeCourses -> - key(sameTimeCourses.joinToString("-") { it.hashCode().toString() }) { - OverviewCourseGroupCard( - courses = sameTimeCourses, - colors = courseColors, - cellWidth = cellWidth, - cellHeight = cellHeight, - currentWeek = pageWeek, - onClick = { - behaviorReporter.organic(AppActionId.OPEN_COURSE_DETAIL) - detailedCourse = it - } - ) - } + if (!renderCourseCards) { + Unit + } else if (isOverviewSchedule) { + overviewCourseGroups.forEach { sameTimeCourses -> + key(sameTimeCourses.joinToString("-") { it.hashCode().toString() }) { + OverviewCourseGroupCard( + courses = sameTimeCourses, + colors = courseColors, + cellWidth = cellWidth, + cellHeight = cellHeight, + currentWeek = pageWeek, + onClick = { + behaviorReporter.organic(AppActionId.OPEN_COURSE_DETAIL) + detailedCourse = it + } + ) } + } } else { - currentWeekCourses.forEach { course -> - val isCurrentWeek = pageWeek in course.weekIndexes - if (isCurrentWeek) { - key(course.hashCode()) { - - CourseCard( - course = course, - color = courseColors.getOrElse(course.name) { 50.a1 }, - cellWidth = cellWidth, - cellHeight = cellHeight, - isCurrentWeek = isCurrentWeek, - onClick = { - behaviorReporter.organic(AppActionId.OPEN_COURSE_DETAIL) - detailedCourse = it - } - ) - } + coursesByWeek.getOrElse(page) { emptyList() }.forEach { course -> + key(course.hashCode()) { + CourseCard( + course = course, + color = courseColors.getOrElse(course.name) { 50.a1 }, + cellWidth = cellWidth, + cellHeight = cellHeight, + isCurrentWeek = true, + onClick = { + behaviorReporter.organic(AppActionId.OPEN_COURSE_DETAIL) + detailedCourse = it + } + ) } } } @@ -517,6 +494,98 @@ fun Schedule( } } +@Composable +private fun BoxScope.ScheduleGridLabels( + weekDates: List, + cellWidth: Dp, + cellHeight: Dp, + pageWeek: Int, + currentWeek: Int?, + currentWeekday: Int, + isInSemester: Boolean, + isPreviewNextSemester: Boolean +) { + val textMeasurer = rememberTextMeasurer() + val contentColor = LocalContentColor.current + val secondaryColor = 50.n1 withNight 80.n1 + val selectedBackground = 90.a1 + val selectedContent = 0.n1 + val dayStyle = MaterialTheme.typography.labelLarge + val secondaryStyle = MaterialTheme.typography.labelSmall + val dayNames = remember { + listOf("周一", "周二", "周三", "周四", "周五", "周六", "周日") + } + val timeLabels = remember { + ScheduleViewModel.timetable.map { (index, time) -> + index.toString() to time.substringBefore("-") + } + } + + Canvas(modifier = Modifier.fillMaxSize()) { + fun drawCentered(text: String, style: TextStyle, center: Offset) { + val result = textMeasurer.measure(text = text, style = style) + drawText( + textLayoutResult = result, + topLeft = Offset( + x = center.x - result.size.width / 2f, + y = center.y - result.size.height / 2f + ) + ) + } + + val cellWidthPx = cellWidth.toPx() + val cellHeightPx = cellHeight.toPx() + val mainColumnWidthPx = CourseCardSpec.mainColumnWidth.toPx() + val mainRowHeightPx = CourseCardSpec.mainRowHeight.toPx() + val spacingPx = CourseCardSpec.cellSpacing.toPx() + val cornerRadius = CornerRadius(8.dp.toPx()) + + weekDates.forEachIndexed { index, date -> + val left = mainColumnWidthPx + (cellWidthPx + spacingPx) * index + spacingPx + val isCurrentWeekday = !isPreviewNextSemester && + isInSemester && + pageWeek == currentWeek && + index + 1 == currentWeekday + if (isCurrentWeekday) { + drawRoundRect( + color = selectedBackground, + topLeft = Offset(left, 0f), + size = Size(cellWidthPx, mainRowHeightPx), + cornerRadius = cornerRadius + ) + } + val color = if (isCurrentWeekday) selectedContent else contentColor + val dateColor = if (isCurrentWeekday) selectedContent else secondaryColor + val centerX = left + cellWidthPx / 2f + drawCentered( + text = dayNames.getOrElse(index) { "" }, + style = dayStyle.copy(color = color), + center = Offset(centerX, mainRowHeightPx * 0.34f) + ) + drawCentered( + text = date, + style = secondaryStyle.copy(color = dateColor), + center = Offset(centerX, mainRowHeightPx * 0.70f) + ) + } + + timeLabels.forEachIndexed { itemIndex, (section, time) -> + val top = mainRowHeightPx + (cellHeightPx + spacingPx) * itemIndex + spacingPx + val centerX = mainColumnWidthPx / 2f + drawCentered( + text = section, + style = dayStyle.copy(color = contentColor), + center = Offset(centerX, top + cellHeightPx * 0.34f) + ) + drawCentered( + text = time, + style = secondaryStyle.copy(color = secondaryColor), + center = Offset(centerX, top + cellHeightPx * 0.70f) + ) + } + } +} + private fun scheduleResultBucket(count: Int): ResultCountBucket = when (count) { 0 -> ResultCountBucket.ZERO in 1..5 -> ResultCountBucket.ONE_TO_FIVE @@ -533,8 +602,17 @@ private fun ScheduleSettingsDialog( onPreviewNextSemesterChange: (Boolean) -> Unit, onDismiss: () -> Unit ) { + val dialogShape = SmoothRoundedCornerShape(28.dp) AlertDialog( - containerColor = backdropColor, + modifier = Modifier.appLiquidGlassSurface( + shape = dialogShape, + fallbackColor = backdropColor, + level = LiquidGlassSurfaceLevel.Floating, + backdropSamplingEnabled = false + ), + shape = dialogShape, + containerColor = Color.Transparent, + tonalElevation = 0.dp, onDismissRequest = onDismiss, title = { Text( @@ -604,10 +682,11 @@ private fun ScheduleSettingsDialog( style = MaterialTheme.typography.bodySmall ) } - Switch( + AppToggle( checked = selected, onCheckedChange = onSelect, - modifier = Modifier.padding(start = 16.dp) + modifier = Modifier.padding(start = 16.dp), + contentDescription = title ) } } @@ -653,11 +732,11 @@ private fun OverviewCourseGroupCard( sortedCourses.forEachIndexed { index, item -> val isCurrentWeek = currentWeek in item.weekIndexes val color = colors.getOrElse(item.name) { 50.a1 } + val tonalPalettes = remember(color) { + courseTonalPalettes(color) + } CompositionLocalProvider( - LocalTonalPalettes provides color.toTonalPalettes( - style = PaletteStyle.Vibrant, - tonalValues = doubleArrayOf() - ) + LocalTonalPalettes provides tonalPalettes ) { Box( modifier = Modifier diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/SchoolCalendar.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/SchoolCalendar.kt index cb651fca..ab2cee0a 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/SchoolCalendar.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/SchoolCalendar.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -46,6 +45,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale @@ -60,7 +60,13 @@ import com.ahu.ahutong.R import com.ahu.ahutong.data.AHURepository import com.ahu.ahutong.data.dao.AHUCache import com.ahu.ahutong.data.mock.MockScenarioController +import com.ahu.ahutong.ui.components.LocalIsLiquidGlassEnabled +import com.ahu.ahutong.ui.components.LocalAppUiTheme +import com.ahu.ahutong.ui.components.AppCircularProgressIndicator +import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.data.model.AppUiTheme import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.ahu.ahutong.utils.FileUtils import com.kyant.capsule.ContinuousCapsule import com.kyant.monet.a1 @@ -69,6 +75,7 @@ import com.kyant.monet.withNight import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import top.yukonga.miuix.kmp.theme.MiuixTheme import java.io.File @Composable @@ -81,6 +88,7 @@ fun SchoolCalendar(navController: NavHostController) { var isLoading by remember { mutableStateOf(false) } var progress by remember { mutableFloatStateOf(0f) } val mockRefreshRevision by MockScenarioController.refreshRevisions().collectAsState() + val chromeContentColor = Color.White val fetchCalendar = { scope.launch(Dispatchers.IO) { @@ -196,7 +204,9 @@ fun SchoolCalendar(navController: NavHostController) { Row( modifier = Modifier .align(Alignment.BottomEnd) - .background(Color.Black.copy(alpha = 0.4f)) + .padding(16.dp) + .clip(SmoothRoundedCornerShape(20.dp)) + .background(Color.Black.copy(alpha = 0.68f)) .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically @@ -214,10 +224,10 @@ fun SchoolCalendar(navController: NavHostController) { } } }) { - Text("保存", color = Color.White) + Text("保存", color = chromeContentColor) } TextButton(onClick = { navController.popBackStack() }) { - Text("退出", color = Color.White) + Text("退出", color = chromeContentColor) } } } @@ -230,11 +240,11 @@ fun SchoolCalendar(navController: NavHostController) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { - CircularProgressIndicator() + AppCircularProgressIndicator() Text( text = if (progress > 0f) "正在下载 ${(progress * 100).toInt()}%" else "正在获取校历...", style = MaterialTheme.typography.bodyMedium, - color = Color.White + color = chromeContentColor ) } } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Tools.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Tools.kt index 8848bd89..a35f347b 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Tools.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Tools.kt @@ -1,6 +1,5 @@ package com.ahu.ahutong.ui.screen.main -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -25,7 +24,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -70,6 +68,10 @@ import com.ahu.ahutong.data.dao.AHUCache import com.ahu.ahutong.utils.FileUtils import com.ahu.ahutong.R import com.ahu.ahutong.appwidget.ScheduleAppWidgetReceiver +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.components.AppButton +import com.ahu.ahutong.ui.components.AppHeaderIconButton import com.ahu.ahutong.ui.screen.main.home.HomeWidgetRegistry import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.kyant.capsule.ContinuousCapsule @@ -81,6 +83,8 @@ import kotlin.system.measureTimeMillis import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.icons.useful.Edit @Composable fun Tools( @@ -110,6 +114,7 @@ fun Tools( Column( modifier = Modifier .fillMaxSize() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1) .verticalScroll(rememberScrollState()) .systemBarsPadding() .padding(bottom = 96.dp), @@ -127,7 +132,10 @@ fun Tools( style = MaterialTheme.typography.headlineMedium ) if (homeEditEnabled) { - IconButton( + AppHeaderIconButton( + imageVector = Icons.Outlined.Edit, + miuixImageVector = MiuixIcons.Useful.Edit, + contentDescription = "编辑首页", onClick = { onEditHome() navController.navigate("home") { @@ -137,12 +145,7 @@ fun Tools( launchSingleTop = true } } - ) { - Icon( - imageVector = Icons.Outlined.Edit, - contentDescription = "编辑首页" - ) - } + ) } } FlowRow( @@ -172,8 +175,10 @@ fun Tools( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) - .clip(SmoothRoundedCornerShape(32.dp)) - .background(100.n1 withNight 30.n1), + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(32.dp), + fallbackColor = 100.n1 withNight 30.n1 + ), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( @@ -181,28 +186,29 @@ fun Tools( modifier = Modifier.padding(24.dp), style = MaterialTheme.typography.titleLarge ) - Image( - painter = painterResource(id = R.mipmap.schedule_widget_prev), + AsyncImage( + model = ImageRequest.Builder(context) + .data(R.mipmap.schedule_widget_prev) + .crossfade(false) + .build(), contentDescription = "桌面课表微件", - modifier = Modifier.align(Alignment.CenterHorizontally) + modifier = Modifier.align(Alignment.CenterHorizontally), + contentScale = ContentScale.Fit ) - Text( - text = "添加", + AppButton( + onClick = { + scope.launch { + GlanceAppWidgetManager(context).requestPinGlanceAppWidget( + ScheduleAppWidgetReceiver::class.java + ) + } + }, modifier = Modifier .padding(16.dp) - .clip(ContinuousCapsule) - .background(90.a1) - .clickable { - scope.launch { - GlanceAppWidgetManager(context).requestPinGlanceAppWidget( - ScheduleAppWidgetReceiver::class.java - ) - } - } - .padding(16.dp, 8.dp), - color = 0.n1, - style = MaterialTheme.typography.titleMedium - ) + .fillMaxWidth() + ) { + Text("添加", style = MaterialTheme.typography.titleMedium) + } } } } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Weather.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Weather.kt index b0696060..5714fa7a 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Weather.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Weather.kt @@ -1,5 +1,7 @@ package com.ahu.ahutong.ui.screen.main +import com.ahu.ahutong.ui.components.AppCircularProgressIndicator + import android.Manifest import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult @@ -26,22 +28,41 @@ import androidx.compose.runtime.saveable.rememberSaveable 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.lerp +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import com.ahu.ahutong.data.weather.WeatherResponse +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.components.AppComponentTokens +import com.ahu.ahutong.ui.components.AppButton +import com.ahu.ahutong.ui.components.AppHeaderIconButton +import com.ahu.ahutong.ui.components.AppScrollablePageLayout +import com.ahu.ahutong.ui.components.AppSearchField +import com.ahu.ahutong.ui.components.AppToggle +import com.ahu.ahutong.ui.components.AppFilterChip import com.ahu.ahutong.ui.state.WeatherHomeMode import com.ahu.ahutong.ui.state.WeatherViewModel +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.n1 import com.kyant.monet.a1 import com.kyant.monet.withNight +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.icons.useful.Cancel +import top.yukonga.miuix.kmp.icon.icons.useful.Refresh +import top.yukonga.miuix.kmp.icon.icons.useful.Search +import top.yukonga.miuix.kmp.icon.icons.useful.Settings @OptIn(ExperimentalMaterial3Api::class) @Composable fun Weather( - weatherViewModel: WeatherViewModel = hiltViewModel() + weatherViewModel: WeatherViewModel = hiltViewModel(), + onBack: (() -> Unit)? = null ) { val context = LocalContext.current val weather = weatherViewModel.weather @@ -72,87 +93,65 @@ fun Weather( } } - Column( + val submitCitySearch = { + if (searchCity.isNotBlank()) { + weatherViewModel.fetchWeather(searchCity) + showSearch = false + } + } + + AppScrollablePageLayout( + title = weatherViewModel.locationName.ifBlank { "天气" }, + onBack = onBack, modifier = Modifier .fillMaxSize() - .systemBarsPadding() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - if (showSearch) { - IconButton(onClick = { - showSearch = false - searchCity = "" - }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "关闭搜索") + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), + bottomPadding = 48.dp, + actions = { + AppHeaderIconButton( + imageVector = if (showSearch) Icons.Default.Close else Icons.Default.Search, + miuixImageVector = if (showSearch) MiuixIcons.Useful.Cancel else MiuixIcons.Useful.Search, + contentDescription = if (showSearch) "关闭搜索" else "搜索城市", + onClick = { + showSearch = !showSearch + if (!showSearch) searchCity = "" } - val doSearch = { - if (searchCity.isNotBlank()) { - weatherViewModel.fetchWeather(searchCity) - showSearch = false - } + ) + AppHeaderIconButton( + imageVector = Icons.Default.Settings, + miuixImageVector = MiuixIcons.Useful.Settings, + contentDescription = "设置", + onClick = { showSettings = true } + ) + AppHeaderIconButton( + imageVector = Icons.Default.Refresh, + miuixImageVector = MiuixIcons.Useful.Refresh, + contentDescription = "刷新", + onClick = { + weatherViewModel.refresh() + Toast.makeText(context, "已刷新", Toast.LENGTH_SHORT).show() } - OutlinedTextField( + ) + } + ) { + Column(modifier = Modifier.padding(horizontal = AppComponentTokens.HeaderHorizontalPadding)) { + if (showSearch) { + AppSearchField( value = searchCity, onValueChange = { searchCity = it }, - modifier = Modifier.weight(1f), - singleLine = true, - placeholder = { Text("输入城市名,如 合肥") }, - colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = 0.n1 withNight 100.n1, - unfocusedTextColor = 0.n1 withNight 100.n1, - cursorColor = 90.a1 withNight 90.a1, - ), - keyboardOptions = KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { doSearch() }), - trailingIcon = { - if (searchCity.isNotEmpty()) { - IconButton(onClick = { searchCity = "" }) { - Icon(Icons.Default.Close, "清空") - } - } else { - IconButton(onClick = { doSearch() }) { - Icon(Icons.Default.Search, "搜索") - } - } - } + modifier = Modifier.fillMaxWidth(), + placeholder = "输入城市名,如 合肥", + onSearch = { submitCitySearch() } ) - } else { - Text( - text = weatherViewModel.locationName.ifBlank { "天气" }, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - Row { - IconButton(onClick = { showSearch = true }) { - Icon(Icons.Default.Search, "搜索城市") - } - IconButton(onClick = { showSettings = true }) { - Icon(Icons.Default.Settings, "设置") - } - IconButton(onClick = { - weatherViewModel.refresh() - Toast.makeText(context, "已刷新", Toast.LENGTH_SHORT).show() - }) { - Icon(Icons.Default.Refresh, "刷新") - } - } + Spacer(Modifier.height(16.dp)) } - } if (weatherViewModel.isLoading) { Box( modifier = Modifier.fillMaxWidth().padding(48.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + AppCircularProgressIndicator() } } else if (weatherViewModel.errorMessage != null) { Column( @@ -161,7 +160,7 @@ fun Weather( ) { Text(weatherViewModel.errorMessage!!, color = MaterialTheme.colorScheme.error) Spacer(Modifier.height(16.dp)) - Button(onClick = { weatherViewModel.refresh() }) { + AppButton(onClick = { weatherViewModel.refresh() }) { Text("重试") } } @@ -216,14 +215,23 @@ fun Weather( } Spacer(Modifier.height(24.dp)) + } } } if (showSettings) { val config = weatherViewModel.homeConfig + val sheetShape = BottomSheetDefaults.ExpandedShape ModalBottomSheet( onDismissRequest = { showSettings = false }, - containerColor = 100.n1 withNight 15.n1, + modifier = Modifier.appLiquidGlassSurface( + shape = sheetShape, + fallbackColor = 100.n1 withNight 15.n1, + level = LiquidGlassSurfaceLevel.Floating, + backdropSamplingEnabled = false + ), + shape = sheetShape, + containerColor = Color.Transparent, tonalElevation = 0.dp ) { Column( @@ -298,7 +306,11 @@ fun Weather( verticalAlignment = Alignment.CenterVertically ) { Text(item.label, modifier = Modifier.weight(1f), color = 0.n1 withNight 100.n1) - Switch(checked = item.value, onCheckedChange = item.onChange) + AppToggle( + checked = item.value, + onCheckedChange = item.onChange, + contentDescription = item.label + ) } } @@ -314,33 +326,31 @@ private fun WeatherModeChip( selected: Boolean, onClick: () -> Unit ) { - FilterChip( + AppFilterChip( selected = selected, onClick = onClick, label = { Text( text = text, - color = if (selected) { - 100.n1 withNight 100.n1 - } else { - 0.n1 withNight 100.n1 - } + style = MaterialTheme.typography.labelLarge ) - }, - colors = FilterChipDefaults.filterChipColors( - containerColor = 100.n1 withNight 20.n1, - labelColor = 0.n1 withNight 100.n1, - selectedContainerColor = 85.a1 withNight 35.a1, - selectedLabelColor = 100.n1 withNight 100.n1 - ) + } ) } @Composable private fun WeatherCard(weather: WeatherResponse) { + val shape = AppComponentTokens.CardShape Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = 90.a1 withNight 30.a1) + modifier = Modifier + .fillMaxWidth() + .appLiquidGlassSurface( + shape = shape, + fallbackColor = 90.a1 withNight 30.a1, + level = LiquidGlassSurfaceLevel.Panel + ), + shape = shape, + colors = CardDefaults.cardColors(containerColor = Color.Transparent) ) { Column( modifier = Modifier.padding(20.dp), @@ -391,9 +401,17 @@ private fun InfoItem(label: String, value: String) { @Composable private fun ForecastCard(day: com.ahu.ahutong.data.weather.ForecastDay) { + val shape = AppComponentTokens.CardShape Card( - modifier = Modifier.width(100.dp), - colors = CardDefaults.cardColors(containerColor = 100.n1 withNight 20.n1) + modifier = Modifier + .width(100.dp) + .appLiquidGlassSurface( + shape = shape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainer, + level = LiquidGlassSurfaceLevel.Panel + ), + shape = shape, + colors = CardDefaults.cardColors(containerColor = Color.Transparent) ) { Column( modifier = Modifier.padding(12.dp), @@ -419,9 +437,17 @@ private fun AqiCard(weather: WeatherResponse) { 6 -> androidx.compose.ui.graphics.Color(0xFF880E4F) else -> androidx.compose.ui.graphics.Color.Gray } + val shape = AppComponentTokens.CardShape Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = 100.n1 withNight 20.n1) + modifier = Modifier + .fillMaxWidth() + .appLiquidGlassSurface( + shape = shape, + fallbackColor = 100.n1 withNight 20.n1, + level = LiquidGlassSurfaceLevel.Panel + ), + shape = shape, + colors = CardDefaults.cardColors(containerColor = Color.Transparent) ) { Row( modifier = Modifier.padding(16.dp), @@ -487,9 +513,17 @@ private fun UmbrellaCard(weather: WeatherResponse) { else androidx.compose.ui.graphics.Color(0xFF4CAF50).copy(alpha = 0.15f) + val shape = AppComponentTokens.CardShape Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = bgColor) + modifier = Modifier + .fillMaxWidth() + .appLiquidGlassSurface( + shape = shape, + fallbackColor = bgColor, + level = LiquidGlassSurfaceLevel.Panel + ), + shape = shape, + colors = CardDefaults.cardColors(containerColor = Color.Transparent) ) { Row( modifier = Modifier.padding(16.dp), @@ -513,9 +547,17 @@ private fun HourlyCard(h: com.ahu.ahutong.data.weather.HourlyForecast) { val datePart = timeStr.substringAfter("-").take(5) // "MM-DD" val hour = timeStr.substringAfter(sep).take(2) // "HH" val label = if (datePart.length == 5 && hour.length == 2) "${datePart}日${hour}时" else timeStr + val shape = AppComponentTokens.CardShape Card( - modifier = Modifier.width(88.dp), - colors = CardDefaults.cardColors(containerColor = 100.n1 withNight 20.n1) + modifier = Modifier + .width(88.dp) + .appLiquidGlassSurface( + shape = shape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainer, + level = LiquidGlassSurfaceLevel.Panel + ), + shape = shape, + colors = CardDefaults.cardColors(containerColor = Color.Transparent) ) { Column( modifier = Modifier.padding(8.dp), @@ -532,6 +574,12 @@ private fun HourlyCard(h: com.ahu.ahutong.data.weather.HourlyForecast) { @Composable private fun LifeIndicesGrid(indices: com.ahu.ahutong.data.weather.LifeIndices) { + val scheme = MaterialTheme.colorScheme + val ratingColor = if (scheme.background.luminance() > 0.5f) { + lerp(scheme.primary, Color.Black, 0.18f) + } else { + scheme.primary + } val items = listOf( "穿衣" to indices.clothing, "紫外线" to indices.uv, @@ -552,13 +600,26 @@ private fun LifeIndicesGrid(indices: com.ahu.ahutong.data.weather.LifeIndices) { horizontalArrangement = Arrangement.spacedBy(8.dp) ) { row.forEach { (label, item) -> + val shape = AppComponentTokens.CardShape Card( - modifier = Modifier.weight(1f), - colors = CardDefaults.cardColors(containerColor = 100.n1 withNight 20.n1) + modifier = Modifier + .weight(1f) + .appLiquidGlassSurface( + shape = shape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainer, + level = LiquidGlassSurfaceLevel.Panel + ), + shape = shape, + colors = CardDefaults.cardColors(containerColor = Color.Transparent) ) { Column(modifier = Modifier.padding(12.dp)) { Text(label, fontWeight = FontWeight.Bold, fontSize = 14.sp) - Text(item!!.level ?: "", color = 90.a1 withNight 85.a1, fontSize = 13.sp) + Text( + item!!.level ?: "", + color = ratingColor, + fontWeight = FontWeight.Medium, + fontSize = 13.sp + ) if (!item.brief.isNullOrBlank()) { Text(item.brief, style = MaterialTheme.typography.bodySmall, color = 50.n1 withNight 80.n1) } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/AtAGlance.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/AtAGlance.kt index 282cee74..cc0d42ba 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/AtAGlance.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/AtAGlance.kt @@ -20,21 +20,18 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.navigation.NavHostController -import com.ahu.ahutong.data.debug.DebugClock import com.ahu.ahutong.data.model.Course import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.ScheduleViewModel import com.kyant.monet.a1 import com.kyant.monet.withNight -import java.text.SimpleDateFormat -import java.util.Locale @Composable fun AtAGlance( todayCourses: List, currentMinutes: Int, - navController: NavHostController, + currentDateText: String, + onOpenSchedule: () -> Unit, isInSemester: Boolean = true, enabled: Boolean = true, trailingContent: @Composable RowScope.() -> Unit = {} @@ -55,7 +52,6 @@ fun AtAGlance( } else { false } - val date = SimpleDateFormat("MM-dd / EE", Locale.CHINA).format(DebugClock.nowDate()) Column( modifier = Modifier.padding(vertical = 0.dp), verticalArrangement = Arrangement.spacedBy(32.dp) @@ -68,7 +64,7 @@ fun AtAGlance( verticalAlignment = Alignment.CenterVertically ) { Text( - text = date, + text = currentDateText, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge ) @@ -80,7 +76,7 @@ fun AtAGlance( .clip(SmoothRoundedCornerShape(32.dp)) .then( if (enabled) { - Modifier.clickable { navController.navigate("schedule") } + Modifier.clickable(onClick = onOpenSchedule) } else { Modifier } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/BathroomOpening.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/BathroomOpening.kt index 44552eda..790a64f2 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/BathroomOpening.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/BathroomOpening.kt @@ -1,6 +1,5 @@ package com.ahu.ahutong.ui.screen.main.home -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -10,10 +9,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.kyant.monet.n1 import com.kyant.monet.withNight @@ -22,14 +21,17 @@ import com.kyant.monet.withNight fun BathroomOpening( navController: NavController, - ) { +) { + val shape = SmoothRoundedCornerShape(24.dp) Column( modifier = Modifier - .clip(SmoothRoundedCornerShape(24.dp)) + .appLiquidGlassSurface( + shape = shape, + fallbackColor = 100.n1 withNight 20.n1 + ) .clickable{ navController.navigate("bathroom_deposit") } - .background(100.n1 withNight 20.n1) .padding(vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/CampusCard.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/CampusCard.kt index 64789461..51aaf5a5 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/CampusCard.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/CampusCard.kt @@ -22,7 +22,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Fullscreen -import androidx.compose.material3.CircularProgressIndicator +import com.ahu.ahutong.ui.components.AppCircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -63,6 +63,7 @@ import com.ahu.ahutong.personalization.prefetch.PaymentQrCommandEntryPoint import com.ahu.ahutong.personalization.runtime.BehaviorRuntimeEntryPoint import com.ahu.ahutong.personalization.action.AppActionId import com.ahu.ahutong.personalization.action.ActionSource +import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.kyant.monet.n1 import com.kyant.monet.withNight import java.util.Locale @@ -160,12 +161,13 @@ private fun CardView( enabled: Boolean, modifier: Modifier = Modifier ) { - - + val shape = SmoothRoundedCornerShape(24.dp) Row( modifier = modifier - .clip(SmoothRoundedCornerShape(24.dp)) - .background(100.n1 withNight 20.n1), + .appLiquidGlassSurface( + shape = shape, + fallbackColor = 100.n1 withNight 20.n1 + ), verticalAlignment = Alignment.CenterVertically ) { @@ -232,12 +234,7 @@ private fun CardView( // Toast.makeText(context, "请安装支付宝", Toast.LENGTH_SHORT).show() // } - val route = if (AHUCache.isCmbCardRechargePreferred()) { - "cmb_card_recharge" - } else { - "card_balance_deposit" - } - navController.navigate(route) + navController.navigate("card_balance_deposit") } } else { Modifier @@ -334,10 +331,13 @@ private fun QRcodeView(balance: Double, onBack: () -> Unit) { } } + val panelShape = SmoothRoundedCornerShape(24.dp) Column( modifier = Modifier - .clip(SmoothRoundedCornerShape(24.dp)) - .background(100.n1 withNight 20.n1) + .appLiquidGlassSurface( + shape = panelShape, + fallbackColor = 100.n1 withNight 20.n1 + ) .padding( start = 20.dp, top = 12.dp, @@ -409,7 +409,7 @@ private fun QRcodeView(balance: Double, onBack: () -> Unit) { text = "加载失败" ) } else { - CircularProgressIndicator() + AppCircularProgressIndicator() } } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/ElectricityPayment.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/ElectricityPayment.kt index f17511ff..ea187a8c 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/ElectricityPayment.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/ElectricityPayment.kt @@ -1,6 +1,5 @@ package com.ahu.ahutong.ui.screen.main.home -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -10,10 +9,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController +import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.kyant.monet.n1 import com.kyant.monet.withNight @@ -23,13 +22,16 @@ import com.kyant.monet.withNight fun ElectricityCard( navController: NavHostController, ) { + val shape = SmoothRoundedCornerShape(24.dp) Column( modifier = Modifier - .clip(SmoothRoundedCornerShape(24.dp)) + .appLiquidGlassSurface( + shape = shape, + fallbackColor = 100.n1 withNight 20.n1 + ) .clickable { navController.navigate("electricity_pay") } - .background(100.n1 withNight 20.n1) .padding(vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally @@ -42,4 +44,4 @@ fun ElectricityCard( ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeWeatherWidget.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeWeatherWidget.kt index f45ba7e1..09a43bab 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeWeatherWidget.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeWeatherWidget.kt @@ -1,6 +1,10 @@ package com.ahu.ahutong.ui.screen.main.home +import com.ahu.ahutong.ui.components.AppCircularProgressIndicator + +import android.Manifest import android.content.Context +import android.content.pm.PackageManager import android.location.Geocoder import android.location.LocationManager import androidx.compose.foundation.clickable @@ -9,13 +13,16 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat import com.ahu.ahutong.data.weather.WeatherApi import com.ahu.ahutong.data.weather.WeatherResponse +import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.WeatherHomeConfig import com.ahu.ahutong.ui.state.WeatherHomeMode @@ -81,10 +88,16 @@ private fun DetailedHomeWeatherCard( config: WeatherHomeConfig, onClick: () -> Unit ) { + val shape = SmoothRoundedCornerShape(32.dp) Card( - modifier = modifier.clickable(onClick = onClick), - shape = SmoothRoundedCornerShape(32.dp), - colors = CardDefaults.cardColors(containerColor = 100.n1 withNight 20.n1) + modifier = modifier + .appLiquidGlassSurface( + shape = shape, + fallbackColor = 100.n1 withNight 20.n1 + ) + .clickable(onClick = onClick), + shape = shape, + colors = CardDefaults.cardColors(containerColor = Color.Transparent) ) { Box( modifier = Modifier @@ -104,8 +117,8 @@ private fun DetailedHomeWeatherCard( ) } else -> { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), + AppCircularProgressIndicator( + size = 24.dp, strokeWidth = 2.dp, color = 70.a1 withNight 85.a1 ) @@ -263,13 +276,18 @@ private fun CompactHomeWeatherCard( hasError: Boolean, onClick: () -> Unit ) { + val shape = SmoothRoundedCornerShape(18.dp) Card( modifier = modifier .widthIn(min = 154.dp, max = 178.dp) - .height(44.dp) + .height(48.dp) + .appLiquidGlassSurface( + shape = shape, + fallbackColor = 100.n1 withNight 20.n1 + ) .clickable(onClick = onClick), - shape = SmoothRoundedCornerShape(18.dp), - colors = CardDefaults.cardColors(containerColor = 100.n1 withNight 20.n1) + shape = shape, + colors = CardDefaults.cardColors(containerColor = Color.Transparent) ) { Box( modifier = Modifier @@ -286,8 +304,8 @@ private fun CompactHomeWeatherCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), + AppCircularProgressIndicator( + size = 16.dp, strokeWidth = 2.dp, color = 70.a1 withNight 85.a1 ) @@ -455,6 +473,16 @@ private fun String.hasAnyWeatherKeyword(vararg keywords: String): Boolean { } private fun getCityFromLocation(context: Context): String? { + val hasFineLocation = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + val hasCoarseLocation = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + if (!hasFineLocation && !hasCoarseLocation) return null + val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return null val location = runCatching { diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeWidgetEditor.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeWidgetEditor.kt index f0bb5042..d4b4c5c8 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeWidgetEditor.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeWidgetEditor.kt @@ -73,7 +73,9 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.navigation.NavHostController +import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.withNight @@ -306,16 +308,20 @@ private fun TextHomeWidgetCard( interactionModifier: Modifier = Modifier ) { val shape = SmoothRoundedCornerShape(24.dp) + val surfaceModifier = if (isHighlighted) { + Modifier + .clip(shape) + .background(90.a1 withNight 35.a1) + } else { + Modifier.appLiquidGlassSurface( + shape = shape, + fallbackColor = 100.n1 withNight 20.n1 + ) + } Box( modifier = modifier .editModeMotion(isEditing) - .clip(shape) - .background( - when { - isHighlighted -> 90.a1 withNight 35.a1 - else -> 100.n1 withNight 20.n1 - } - ) + .then(surfaceModifier) .then(interactionModifier) .padding(horizontal = 16.dp), contentAlignment = Alignment.Center @@ -383,6 +389,7 @@ fun HomeWidgetLibrarySheet( LaunchedEffect(availableWidgets) { itemBounds.keys.retainAll(availableWidgets.map { it.id }.toSet()) } + val sheetShape = SmoothRoundedCornerShape(32.dp) Column( modifier = Modifier .fillMaxWidth() @@ -428,8 +435,11 @@ fun HomeWidgetLibrarySheet( } } } - .clip(SmoothRoundedCornerShape(32.dp)) - .background(100.n1 withNight 18.n1) + .appLiquidGlassSurface( + shape = sheetShape, + fallbackColor = 100.n1 withNight 18.n1, + level = LiquidGlassSurfaceLevel.Floating + ) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/TodayCourseList.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/TodayCourseList.kt index 523c5681..476997ab 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/TodayCourseList.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/TodayCourseList.kt @@ -1,6 +1,5 @@ package com.ahu.ahutong.ui.screen.main.home -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -16,7 +15,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset @@ -28,8 +26,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.navigation.NavHostController import com.ahu.ahutong.data.model.Course +import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.ScheduleViewModel import com.kyant.monet.a1 @@ -40,19 +38,22 @@ import com.kyant.monet.withNight fun TodayCourseList( todayCourses: List, currentMinutes: Int, - navController: NavHostController?, + onOpenSchedule: () -> Unit, enabled: Boolean = true ) { + val panelShape = SmoothRoundedCornerShape(32.dp) if (todayCourses.isEmpty()) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) - .clip(SmoothRoundedCornerShape(32.dp)) - .background(100.n1 withNight 20.n1) + .appLiquidGlassSurface( + shape = panelShape, + fallbackColor = 100.n1 withNight 20.n1 + ) .then( if (enabled) { - Modifier.clickable { navController?.navigate("schedule") } + Modifier.clickable(onClick = onOpenSchedule) } else { Modifier } @@ -85,11 +86,13 @@ fun TodayCourseList( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) - .clip(SmoothRoundedCornerShape(32.dp)) - .background(100.n1 withNight 20.n1) + .appLiquidGlassSurface( + shape = panelShape, + fallbackColor = 100.n1 withNight 20.n1 + ) .then( if (enabled) { - Modifier.clickable { navController?.navigate("schedule") } + Modifier.clickable(onClick = onOpenSchedule) } else { Modifier } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/schedule/CourseCard.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/schedule/CourseCard.kt index 0172d15e..c707e7d5 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/schedule/CourseCard.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/schedule/CourseCard.kt @@ -11,11 +11,16 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember 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.input.pointer.pointerInput +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -28,10 +33,22 @@ import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.kyant.monet.LocalTonalPalettes import com.kyant.monet.PaletteStyle import com.kyant.monet.TonalPalettes.Companion.toTonalPalettes +import com.kyant.monet.TonalPalettes import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.n2 import com.kyant.monet.withNight +import java.util.concurrent.ConcurrentHashMap + +private val coursePaletteCache = ConcurrentHashMap() + +internal fun courseTonalPalettes(color: Color): TonalPalettes = + coursePaletteCache.getOrPut(color.toArgb()) { + color.toTonalPalettes( + style = PaletteStyle.Vibrant, + tonalValues = doubleArrayOf() + ) + } @Composable fun CourseCard( @@ -42,10 +59,9 @@ fun CourseCard( isCurrentWeek: Boolean = true, onClick: (Course) -> Unit ) { + val tonalPalettes = remember(color) { courseTonalPalettes(color) } CompositionLocalProvider( - LocalTonalPalettes provides color.toTonalPalettes( - style = PaletteStyle.Vibrant, tonalValues = doubleArrayOf() // 此行代码解决了卡顿问题 - ) + LocalTonalPalettes provides tonalPalettes ) { Box( modifier = with(CourseCardSpec) { @@ -59,6 +75,24 @@ fun CourseCard( ) .clip(SmoothRoundedCornerShape(8.dp)) .background(if (!isCurrentWeek) Color.Gray else color) + .semantics(mergeDescendants = true) { + contentDescription = buildString { + append(course.name) + if (!course.location.isNullOrBlank()) { + append(",") + append(course.location) + } + append(",第") + append(course.startTime) + append("至") + append(course.startTime + course.length - 1) + append("节") + } + onClick(label = "查看课程详情") { + onClick(course) + true + } + } .pointerInput(Unit) { detectTapGestures { onClick(course) } } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/schedule/CourseDetailDialog.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/schedule/CourseDetailDialog.kt index 45bcf089..0afaa42d 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/schedule/CourseDetailDialog.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/schedule/CourseDetailDialog.kt @@ -21,12 +21,13 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import com.ahu.ahutong.data.model.Course +import com.ahu.ahutong.ui.components.AppDialogSurface +import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.n1 import com.kyant.monet.withNight @@ -47,11 +48,9 @@ fun CourseDetailDialog( 6 to "六", 7 to "七" ) - Dialog(onDismissRequest = onDismiss) { + AppDialogSurface(onDismissRequest = onDismiss) { Column( - modifier = Modifier - .clip(SmoothRoundedCornerShape(32.dp)) - .background(96.n1 withNight 10.n1) + modifier = Modifier.fillMaxWidth() ) { Column( modifier = Modifier.padding(24.dp), diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/settings/Contributors.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/settings/Contributors.kt index 99ed2a4d..a82dca1e 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/settings/Contributors.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/settings/Contributors.kt @@ -1,18 +1,19 @@ package com.ahu.ahutong.ui.screen.settings -import androidx.compose.foundation.background import androidx.compose.foundation.clickable 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -25,7 +26,9 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.ahu.ahutong.R -import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.ahu.ahutong.ui.components.SettingsBackdropContainer +import com.ahu.ahutong.ui.components.SettingsPageLayout +import com.ahu.ahutong.ui.components.SettingsSection import com.ahu.ahutong.ui.state.DeveloperViewModel import com.kyant.capsule.ContinuousCapsule import com.kyant.monet.n1 @@ -33,93 +36,88 @@ import com.kyant.monet.withNight @Composable fun Contributors( + onBack: () -> Unit, developerViewModel: DeveloperViewModel = viewModel() ) { val context = LocalContext.current - 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.contributors), - modifier = Modifier.padding(24.dp, 32.dp), - style = MaterialTheme.typography.headlineLarge - ) - mapOf( - developerViewModel.partners to stringResource(id = R.string.mine_tv_partner), - developerViewModel.developers to stringResource(id = R.string.mine_tv_developer), - ).forEach { (list, name) -> - Text( - text = name, - modifier = Modifier.padding(horizontal = 24.dp), - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium - ) - Column( - modifier = Modifier.clip(SmoothRoundedCornerShape(32.dp)), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - list.forEach { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(4.dp)) - .background(100.n1 withNight 20.n1) - .clickable { it.onclick(context) } - .padding(24.dp, 16.dp), - horizontalArrangement = Arrangement.spacedBy(24.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // oh shit, Compose is too hard for me... - when (it) { - is DeveloperViewModel.Developer -> { - AsyncImage( - model = it.img, - modifier = Modifier - .size(64.dp) - .clip(ContinuousCapsule), - contentDescription = null - ) - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = it.name, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium - ) - Text( - text = it.desc, - color = 30.n1 withNight 90.n1, - style = MaterialTheme.typography.bodyLarge - ) - Text( - text = "QQ: ${it.qq}", - color = 50.n1 withNight 80.n1, - style = MaterialTheme.typography.bodyMedium + SettingsBackdropContainer(modifier = Modifier.fillMaxSize()) { backdrop -> + SettingsPageLayout( + title = stringResource(id = R.string.contributors), + onBack = onBack, + backdrop = backdrop, + modifier = Modifier + .fillMaxSize(), + bottomPadding = 48.dp + ) { + mapOf( + developerViewModel.partners to stringResource(id = R.string.mine_tv_partner), + developerViewModel.developers to stringResource(id = R.string.mine_tv_developer), + ).forEach { (list, name) -> + SettingsSection( + title = name, + modifier = Modifier.padding(horizontal = 16.dp), + backdrop = backdrop + ) { + list.forEachIndexed { index, contributor -> + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 72.dp) + .clickable { contributor.onclick(context) } + .padding(horizontal = 20.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + when (contributor) { + is DeveloperViewModel.Developer -> { + AsyncImage( + model = contributor.img, + modifier = Modifier + .size(64.dp) + .clip(ContinuousCapsule), + contentDescription = null ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = contributor.name, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = contributor.desc, + color = 30.n1 withNight 90.n1, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "QQ: ${contributor.qq}", + color = 50.n1 withNight 80.n1, + style = MaterialTheme.typography.bodyMedium + ) + } } - } - is DeveloperViewModel.Partner -> { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = it.name, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium - ) - Text( - text = it.desc, - color = 30.n1 withNight 90.n1, - style = MaterialTheme.typography.bodyLarge - ) + is DeveloperViewModel.Partner -> { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = contributor.name, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = contributor.desc, + color = 30.n1 withNight 90.n1, + style = MaterialTheme.typography.bodyLarge + ) + } } } } - + if (index != list.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(start = 20.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f) + ) + } } } } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/settings/License.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/settings/License.kt index 4d405010..4c39652c 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/settings/License.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/settings/License.kt @@ -2,18 +2,19 @@ package com.ahu.ahutong.ui.screen.settings import android.content.Intent import android.net.Uri -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -23,7 +24,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -31,13 +31,19 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.ahu.ahutong.R import com.ahu.ahutong.data.model.License as LicenseItem +import com.ahu.ahutong.ui.components.SettingsBackdropContainer +import com.ahu.ahutong.ui.components.SettingsPageLayout +import com.ahu.ahutong.ui.components.SettingsSection +import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.LicenseViewModel +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.n1 import com.kyant.monet.withNight @Composable fun License( + onBack: () -> Unit, licenseViewModel: LicenseViewModel = viewModel() ) { val context = LocalContext.current @@ -51,60 +57,62 @@ fun License( ) } - 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.license), - modifier = Modifier.padding(24.dp, 32.dp), - style = MaterialTheme.typography.headlineLarge - ) - Column( - modifier = Modifier.clip(SmoothRoundedCornerShape(32.dp)), - verticalArrangement = Arrangement.spacedBy(2.dp) + SettingsBackdropContainer(modifier = Modifier.fillMaxSize()) { backdrop -> + SettingsPageLayout( + title = stringResource(id = R.string.license), + onBack = onBack, + backdrop = backdrop, + modifier = Modifier + .fillMaxSize(), + bottomPadding = 48.dp ) { - licenseViewModel.license.forEach { - Column( - modifier = Modifier - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(4.dp)) - .background(100.n1 withNight 20.n1) - .clickable { - if (it.licenseAsset != null || it.noticeAsset != null) { - selectedLicense = it - } else { - openSource(it) + SettingsSection( + title = "开源组件", + modifier = Modifier.padding(horizontal = 16.dp), + backdrop = backdrop + ) { + licenseViewModel.license.forEachIndexed { index, license -> + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 68.dp) + .clickable { + if (license.licenseAsset != null || license.noticeAsset != null) { + selectedLicense = license + } else { + openSource(license) + } } - } - .padding(24.dp, 16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = it.name, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium - ) - Text( - text = it.author, - color = 30.n1 withNight 90.n1, - style = MaterialTheme.typography.bodyLarge - ) - Text( - text = it.url, - color = 50.n1 withNight 80.n1, - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = it.license, - color = 50.n1 withNight 80.n1, - style = MaterialTheme.typography.bodySmall - ) + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = license.name, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = license.author, + color = 30.n1 withNight 90.n1, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = license.url, + color = 50.n1 withNight 80.n1, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = license.license, + color = 50.n1 withNight 80.n1, + style = MaterialTheme.typography.bodySmall + ) + } + if (index != licenseViewModel.license.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(start = 20.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f) + ) + } } } } @@ -123,8 +131,18 @@ fun License( .joinToString("\n\n") } + val dialogShape = SmoothRoundedCornerShape(28.dp) AlertDialog( + modifier = Modifier.appLiquidGlassSurface( + shape = dialogShape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainerHigh, + level = LiquidGlassSurfaceLevel.Floating, + backdropSamplingEnabled = false + ), onDismissRequest = { selectedLicense = null }, + shape = dialogShape, + containerColor = androidx.compose.ui.graphics.Color.Transparent, + tonalElevation = 0.dp, title = { Text(text = license.name) }, 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 8982946c..e204a3ff 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 @@ -18,9 +18,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -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.rounded.Add import androidx.compose.material.icons.rounded.Check @@ -29,6 +27,7 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -47,19 +46,23 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import com.ahu.ahutong.data.model.AppThemeMode +import com.ahu.ahutong.data.model.AppUiTheme +import com.ahu.ahutong.data.dao.DEFAULT_THEME_COLOR import com.ahu.ahutong.notification.CourseReminderCapability import com.ahu.ahutong.notification.CourseReminderNotifier import com.ahu.ahutong.notification.CourseReminderScheduler 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.SettingsConfirmationDialog -import com.ahu.ahutong.ui.components.SettingsPageHeader +import com.ahu.ahutong.ui.components.SettingsSelectRow +import com.ahu.ahutong.ui.components.SettingsPageLayout import com.ahu.ahutong.ui.components.SettingsSection import com.ahu.ahutong.ui.components.SettingsToggleRow +import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.PreferencesViewModel +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel @Composable fun Preferences(onBack: () -> Unit = {}) { @@ -78,12 +81,13 @@ fun Preferences(onBack: () -> Unit = {}) { val appThemeMode by viewModel.appThemeMode.collectAsState() val showQRCode by viewModel.showQRCode.collectAsState() - val useCmbCardRecharge by viewModel.useCmbCardRecharge.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 appUiTheme by viewModel.appUiTheme.collectAsState() + val useBuiltInSecurePasswordKeyboard by + viewModel.useBuiltInSecurePasswordKeyboard.collectAsState() val themeColor by viewModel.themeColor.collectAsState() val courseReminderEnabled by viewModel.courseReminderEnabled.collectAsState() val courseReminderLiveCountdownEnabled by @@ -125,19 +129,13 @@ fun Preferences(onBack: () -> Unit = {}) { } SettingsBackdropContainer(modifier = Modifier.fillMaxSize()) { backdrop -> - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll( - state = pageScrollState, - enabled = !isToggleHorizontalDragActive - ) - .systemBarsPadding() - .padding(bottom = 112.dp), - verticalArrangement = Arrangement.spacedBy(26.dp) + SettingsPageLayout( + title = "偏好设置", + onBack = onBack, + backdrop = backdrop, + scrollState = pageScrollState, + scrollEnabled = !isToggleHorizontalDragActive ) { - SettingsPageHeader(title = "偏好设置", onBack = onBack, backdrop = backdrop) - SettingsSection( title = "智能体验", modifier = Modifier.padding(horizontal = 16.dp), @@ -173,9 +171,8 @@ fun Preferences(onBack: () -> Unit = {}) { onHorizontalDragActiveChange = onToggleHorizontalDragActiveChange ) } - SettingsDialogSelectRow( + SettingsSelectRow( title = "本地记录保留期", - dialogTitle = "选择本地记录保留期", selected = behaviorRetentionDays, choices = listOf( SettingsChoice(7, "7 天"), @@ -216,7 +213,6 @@ fun Preferences(onBack: () -> Unit = {}) { } SettingsActionRow( title = "清除本地学习记录", - subtitle = "删除行为统计、训练样本和本地模型", destructive = true, showChevron = false, showDivider = false, @@ -237,10 +233,10 @@ fun Preferences(onBack: () -> Unit = {}) { onHorizontalDragActiveChange = onToggleHorizontalDragActiveChange ) SettingsToggleRow( - title = "总是使用招商银行充值", - subtitle = "校园卡充值将直接进入招商银行页面", - selected = useCmbCardRecharge, - onSelectedChange = viewModel::setUseCmbCardRecharge, + title = "使用内置安全密码键盘", + subtitle = "关闭后使用系统密码键盘", + selected = useBuiltInSecurePasswordKeyboard, + onSelectedChange = viewModel::setUseBuiltInSecurePasswordKeyboard, backdrop = backdrop, showDivider = false, onHorizontalDragActiveChange = onToggleHorizontalDragActiveChange @@ -310,9 +306,8 @@ fun Preferences(onBack: () -> Unit = {}) { modifier = Modifier.padding(horizontal = 16.dp), backdrop = backdrop ) { - SettingsDialogSelectRow( + SettingsSelectRow( title = "深色模式", - dialogTitle = "选择深色模式", selected = appThemeMode, choices = listOf( SettingsChoice(AppThemeMode.FOLLOW_SYSTEM, "跟随系统"), @@ -321,16 +316,16 @@ fun Preferences(onBack: () -> Unit = {}) { ), onSelected = viewModel::setAppThemeMode ) - SettingsToggleRow( - title = "液态玻璃", - subtitle = "使用 Apple 风格的玻璃控件和浮动导航", - selected = useLiquidGlass, - onSelectedChange = viewModel::setUseLiquidGlass, - backdrop = backdrop, - onHorizontalDragActiveChange = onToggleHorizontalDragActiveChange + SettingsSelectRow( + title = "主题", + subtitle = "切换整套界面的组件与交互风格", + selected = appUiTheme, + choices = AppUiTheme.entries.map { SettingsChoice(it, it.displayName) }, + onSelected = viewModel::setAppUiTheme ) ThemeColorPicker( selectedColor = themeColor, + showMiuixDefault = appUiTheme == AppUiTheme.MIUIX, onColorSelected = viewModel::setThemeColor, onCustomColorClick = { showCustomColorDialog = true } ) @@ -354,8 +349,18 @@ fun Preferences(onBack: () -> Unit = {}) { if (showEnableTrainingContribution) { var includeHistorical by remember { mutableStateOf(false) } + val dialogShape = SmoothRoundedCornerShape(28.dp) AlertDialog( + modifier = Modifier.appLiquidGlassSurface( + shape = dialogShape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainerHigh, + level = LiquidGlassSurfaceLevel.Floating, + backdropSamplingEnabled = false + ), onDismissRequest = { showEnableTrainingContribution = false }, + shape = dialogShape, + containerColor = Color.Transparent, + tonalElevation = 0.dp, title = { Text("贡献通用模型训练数据") }, text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -418,20 +423,26 @@ private data class ThemeColorChoice( @Composable private fun ThemeColorPicker( selectedColor: String?, + showMiuixDefault: Boolean, 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 choices = buildList { + if (showMiuixDefault) { + add(ThemeColorChoice(DEFAULT_THEME_COLOR, "默认", Color(0xFF3482FF))) + } + add(ThemeColorChoice(null, "系统", MaterialTheme.colorScheme.primary)) + add(ThemeColorChoice("#FF4A90E2", "极光蓝", Color(0xFF4A90E2))) + add(ThemeColorChoice("#FFE07A9F", "樱花粉", Color(0xFFE07A9F))) + add(ThemeColorChoice("#FFF4A261", "落日橙", Color(0xFFF4A261))) + add(ThemeColorChoice("#FF6A994E", "苔藓绿", Color(0xFF6A994E))) + add(ThemeColorChoice("#FF9B7EDE", "薰衣草", Color(0xFF9B7EDE))) + add(ThemeColorChoice("#FF2E8B57", "翡翠", Color(0xFF2E8B57))) + } val presetValues = choices.map { it.value }.toSet() - val customSelected = selectedColor != null && selectedColor !in presetValues + val customSelected = selectedColor != null && + selectedColor != DEFAULT_THEME_COLOR && + selectedColor !in presetValues Column( modifier = Modifier.fillMaxWidth() @@ -531,8 +542,18 @@ private fun CustomThemeColorDialog( val valid = remember(value) { runCatching { android.graphics.Color.parseColor(value) }.isSuccess } + val dialogShape = SmoothRoundedCornerShape(28.dp) AlertDialog( + modifier = Modifier.appLiquidGlassSurface( + shape = dialogShape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainerHigh, + level = LiquidGlassSurfaceLevel.Floating, + backdropSamplingEnabled = false + ), onDismissRequest = onDismiss, + shape = dialogShape, + containerColor = Color.Transparent, + tonalElevation = 0.dp, title = { Text("自定义主题色") }, text = { OutlinedTextField( @@ -544,6 +565,12 @@ private fun CustomThemeColorDialog( supportingText = { if (value.isNotBlank() && !valid) Text("请输入有效的颜色代码") }, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + errorContainerColor = MaterialTheme.colorScheme.surface + ), singleLine = true, modifier = Modifier.fillMaxWidth() ) diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/setup/Info.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/setup/Info.kt index 9701503b..61b6b5bc 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/setup/Info.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/setup/Info.kt @@ -48,7 +48,10 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.ahu.ahutong.R +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.ahu.ahutong.ui.state.ScheduleViewModel +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.capsule.ContinuousCapsule import com.kyant.monet.a1 import com.kyant.monet.n1 @@ -70,6 +73,7 @@ fun Info( Box( modifier = Modifier .fillMaxSize() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1) .imePadding() ) { Column( @@ -96,8 +100,11 @@ fun Info( onValueChange = { schoolYear = it }, modifier = Modifier .padding(horizontal = 16.dp) - .clip(ContinuousCapsule) - .background(100.n1 withNight 20.n1), + .appLiquidGlassSurface( + shape = ContinuousCapsule, + fallbackColor = 100.n1 withNight 20.n1, + level = LiquidGlassSurfaceLevel.Control + ), textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, @@ -125,8 +132,11 @@ fun Info( LazyRow( modifier = Modifier .padding(horizontal = 16.dp) - .clip(ContinuousCapsule) - .background(100.n1 withNight 20.n1), + .appLiquidGlassSurface( + shape = ContinuousCapsule, + fallbackColor = 100.n1 withNight 20.n1, + level = LiquidGlassSurfaceLevel.Panel + ), contentPadding = PaddingValues(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -155,8 +165,11 @@ fun Info( onValueChange = { currentWeek = it }, modifier = Modifier .padding(horizontal = 16.dp) - .clip(ContinuousCapsule) - .background(100.n1 withNight 20.n1), + .appLiquidGlassSurface( + shape = ContinuousCapsule, + fallbackColor = 100.n1 withNight 20.n1, + level = LiquidGlassSurfaceLevel.Control + ), textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/setup/Login.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/setup/Login.kt index 7229a124..aa8b9e02 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/setup/Login.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/setup/Login.kt @@ -70,8 +70,11 @@ import com.ahu.ahutong.data.crawler.manager.CookieManager import com.ahu.ahutong.data.crawler.manager.TokenManager import com.ahu.ahutong.data.dao.AHUCache import com.ahu.ahutong.sdk.RustSDK +import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground +import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.ahu.ahutong.ui.state.LoginState import com.ahu.ahutong.ui.state.LoginViewModel +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.capsule.ContinuousCapsule import com.kyant.monet.n1 import com.kyant.monet.withNight @@ -151,7 +154,7 @@ fun Login( Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background) + .appLiquidGlassSceneBackground(MaterialTheme.colorScheme.background) ) Column( modifier = Modifier @@ -211,8 +214,11 @@ fun Login( onValueChange = { userID = it }, modifier = Modifier .padding(horizontal = 16.dp) - .clip(ContinuousCapsule) - .background(100.n1 withNight 20.n1) + .appLiquidGlassSurface( + shape = ContinuousCapsule, + fallbackColor = 100.n1 withNight 20.n1, + level = LiquidGlassSurfaceLevel.Control + ) .onFocusChanged { if (it.isFocused) { focusIndex = 0 @@ -255,8 +261,11 @@ fun Login( onValueChange = { password = it }, modifier = Modifier .padding(horizontal = 16.dp) - .clip(ContinuousCapsule) - .background(100.n1 withNight 20.n1) + .appLiquidGlassSurface( + shape = ContinuousCapsule, + fallbackColor = 100.n1 withNight 20.n1, + level = LiquidGlassSurfaceLevel.Control + ) .onFocusChanged { if (it.isFocused) { focusIndex = 1 diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/setup/LoginDynamicIsland.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/setup/LoginDynamicIsland.kt index 4d9f82db..1ab0e779 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/setup/LoginDynamicIsland.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/setup/LoginDynamicIsland.kt @@ -18,7 +18,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.CircularProgressIndicator +import com.ahu.ahutong.ui.components.AppCircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -34,8 +34,10 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.ahu.ahutong.R +import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.LoginState +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.withNight @@ -47,28 +49,40 @@ fun BoxScope.LoginDynamicIsland( succeedMessage: String, onLogIn: () -> Unit ) { + val islandShape = SmoothRoundedCornerShape(32.dp) + val islandColor = animateColorAsState( + targetValue = when (state) { + LoginState.Idle -> 90.a1 withNight 85.a1 + LoginState.InProgress -> 70.a1 withNight 60.a1 + LoginState.WebVerification -> 70.a1 withNight 60.a1 + LoginState.Failed -> MaterialTheme.colorScheme.error + LoginState.Succeeded -> 70.a1 withNight 60.a1 + } + ).value + val idleContentColor = 0.n1 withNight 100.n1 Box( modifier = Modifier .align(Alignment.BottomEnd) .navigationBarsPadding() .padding(16.dp) - .clip(SmoothRoundedCornerShape(32.dp)) // TODO: clip bug - .background( - animateColorAsState( - targetValue = when (state) { - LoginState.Idle -> 90.a1 withNight 85.a1 - LoginState.InProgress -> 70.a1 withNight 60.a1 - LoginState.WebVerification -> 70.a1 withNight 60.a1 - LoginState.Failed -> Color.Red - LoginState.Succeeded -> 70.a1 withNight 60.a1 - } - ).value + .then( + if (state == LoginState.Idle) { + Modifier.appLiquidGlassSurface( + shape = islandShape, + fallbackColor = islandColor, + level = LiquidGlassSurfaceLevel.Floating + ) + } else { + Modifier + .clip(islandShape) + .background(islandColor) + } ) .animateContentSize(spring(stiffness = Spring.StiffnessLow)) ) { when (state) { LoginState.Idle -> { - CompositionLocalProvider(LocalIndication provides ripple(color = 0.n1)) { + CompositionLocalProvider(LocalIndication provides ripple(color = idleContentColor)) { Text( text = stringResource(id = R.string.login), modifier = Modifier @@ -77,7 +91,7 @@ fun BoxScope.LoginDynamicIsland( onClick = onLogIn ) .padding(24.dp, 16.dp), - color = 0.n1, + color = idleContentColor, style = MaterialTheme.typography.titleMedium ) } @@ -91,8 +105,8 @@ fun BoxScope.LoginDynamicIsland( horizontalArrangement = Arrangement.spacedBy(24.dp), verticalAlignment = Alignment.CenterVertically ) { - CircularProgressIndicator( - modifier = Modifier.size(56.dp), + AppCircularProgressIndicator( + size = 56.dp, color = 100.n1, strokeWidth = 6.dp ) diff --git a/app/src/main/java/com/ahu/ahutong/ui/state/BathroomDepositViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/state/BathroomDepositViewModel.kt index a9d85ba5..ebd76a36 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/state/BathroomDepositViewModel.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/state/BathroomDepositViewModel.kt @@ -13,10 +13,13 @@ import com.ahu.ahutong.data.dao.AHUCache import com.ahu.ahutong.data.model.BathroomTelInfo import com.ahu.ahutong.ext.launchSafe import com.google.gson.Gson -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.withContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class BathroomDepositViewModel: ViewModel() { @@ -24,7 +27,12 @@ class BathroomDepositViewModel: ViewModel() { private val _info = MutableStateFlow?>(null) - val info: StateFlow?> = _info + val info: StateFlow?> = _info + + private val _isQuerying = MutableStateFlow(false) + val isQuerying: StateFlow = _isQuerying + + private var queryJob: Job? = null var _payState = MutableStateFlow(PayState.Idle) @@ -34,13 +42,27 @@ class BathroomDepositViewModel: ViewModel() { _payState.value = PayState.Idle } - fun getBathroomInfo(bathroom:String,tel: String){ - viewModelScope.launchSafe { - withContext(Dispatchers.IO){ - _info.value = AHURepository.getBathroomInfo(bathroom = bathroom,tel = tel) - } - } - } + fun clearBathroomInfo() { + queryJob?.cancel() + _isQuerying.value = false + _info.value = null + } + + fun getBathroomInfo(bathroom: String, tel: String) { + if (tel.length != 11) return + queryJob?.cancel() + queryJob = viewModelScope.launch { + _isQuerying.value = true + _info.value = null + try { + _info.value = withContext(Dispatchers.IO) { + AHURepository.getBathroomInfo(bathroom = bathroom, tel = tel) + } + } finally { + _isQuerying.value = false + } + } + } @@ -48,13 +70,13 @@ class BathroomDepositViewModel: ViewModel() { fun pay(bathroom:String,amount: String,password: String){ _payState.value = PayState.InProgress - paymentSuccessEvent.value = Unit - - if(info.value == null) - return + if (info.value?.data?.map?.data == null) { + _payState.value = PayState.Failed("请先查询有效的浴室账户") + return + } viewModelScope.launchSafe { - withContext(Dispatchers.Default){ + withContext(Dispatchers.IO){ info.value!!.data.map!!.data?.let{ //???? val data = it data.myCustomInfo = "手机号:${data.telPhone}" @@ -81,10 +103,15 @@ class BathroomDepositViewModel: ViewModel() { Gson().fromJson(it, PayResponse::class.java) } - if(payResponse?.code == 200){ - _info.value = AHURepository.getBathroomInfo(bathroom = bathroom,tel = data.telPhone) - AHUCache.savePhone(it.telPhone) - _payState.value = PayState.Succeeded(message = payResponse.data) + if(payResponse?.code == 200){ + AHUCache.savePhone(it.telPhone) + _payState.value = PayState.Succeeded(message = payResponse.data) + paymentSuccessEvent.postValue(Unit) + delay(1_000) + _info.value = AHURepository.getBathroomInfo( + bathroom = bathroom, + tel = data.telPhone + ) }else{ _payState.value = PayState.Failed(message = payResponse?.msg?:"未知错误") } diff --git a/app/src/main/java/com/ahu/ahutong/ui/state/CardBalanceDepositViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/state/CardBalanceDepositViewModel.kt index 54026c19..c4407159 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/state/CardBalanceDepositViewModel.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/state/CardBalanceDepositViewModel.kt @@ -7,6 +7,7 @@ import com.ahu.ahutong.data.crawler.model.ycard.CardBalanceRequest import com.ahu.ahutong.data.crawler.model.ycard.CardInfo import com.ahu.ahutong.data.crawler.model.ycard.CardPayRequest import com.ahu.ahutong.data.crawler.model.ycard.PayResponse +import com.ahu.ahutong.data.model.CardRechargeBank import com.ahu.ahutong.ext.launchSafe import com.google.gson.Gson import kotlinx.coroutines.Dispatchers @@ -44,7 +45,7 @@ class CardBalanceDepositViewModel : ViewModel() { } - fun charge(value: String) = viewModelScope.launchSafe { + fun charge(value: String, bank: CardRechargeBank) = viewModelScope.launchSafe { withContext(Dispatchers.IO) { @@ -71,7 +72,7 @@ class CardBalanceDepositViewModel : ViewModel() { target?.let { - val request = CardPayRequest(it) + val request = CardPayRequest(it, bank) try { val response = AHURepository.pay(request) diff --git a/app/src/main/java/com/ahu/ahutong/ui/state/DiscoveryViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/state/DiscoveryViewModel.kt index 3b480c15..f7200a4b 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/state/DiscoveryViewModel.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/state/DiscoveryViewModel.kt @@ -3,7 +3,6 @@ package com.ahu.ahutong.ui.state import android.graphics.Bitmap import android.util.Log import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -21,6 +20,8 @@ import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel import com.journeyapps.barcodescanner.BarcodeEncoder import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext import javax.inject.Inject @@ -44,9 +45,6 @@ class DiscoveryViewModel @Inject constructor( var balance by mutableStateOf(0.0) var transitionBalance by mutableStateOf(0.0) - val visibilities = mutableStateListOf() - - var qrcode = MutableStateFlow(null) var state = MutableStateFlow(false); @@ -59,19 +57,20 @@ class DiscoveryViewModel @Inject constructor( } viewModelScope.launchSafe { - - AHURepository.getCardMoney().onSuccess { + val (cardResult, bathroomResult) = coroutineScope { + val card = async { AHURepository.getCardMoney() } + val bathrooms = async { AHURepository.getBathRooms() } + card.await() to bathrooms.await() + } + cardResult.onSuccess { applyCardBalance(it.balance, it.transitionBalance) } - - AHURepository.getBathRooms().onSuccess { + bathroomResult.onSuccess { bathroom.clear() it.forEach { room -> bathroom += room.bathroom to room.openStatus } } - - } } diff --git a/app/src/main/java/com/ahu/ahutong/ui/state/ElectricityDepositViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/state/ElectricityDepositViewModel.kt index 6598299a..8fd4b6c5 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/state/ElectricityDepositViewModel.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/state/ElectricityDepositViewModel.kt @@ -9,6 +9,11 @@ import com.google.gson.Gson import com.google.gson.annotations.SerializedName import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import okhttp3.FormBody import android.util.Log @@ -18,6 +23,7 @@ import com.ahu.ahutong.data.crawler.utils.generateNonce import com.ahu.ahutong.data.crawler.utils.getTimestamp import com.ahu.ahutong.data.crawler.utils.sha256 import com.ahu.ahutong.data.model.ElectricityChargeInfo +import com.ahu.ahutong.data.model.ElectricityController import com.ahu.ahutong.data.model.ElectricityDepositHistoryItem import com.ahu.ahutong.data.model.RoomSelectionInfo import com.ahu.ahutong.personalization.preset.PresetCandidate @@ -144,6 +150,9 @@ class ElectricityDepositViewModel @Inject constructor( _payState.value = PayState.Idle } + private val _selectedController = MutableStateFlow(AHUCache.getElectricityController()) + val selectedController: StateFlow = _selectedController + private val _campusList = MutableStateFlow>(emptyList()) val campusList: StateFlow> = _campusList @@ -186,36 +195,28 @@ class ElectricityDepositViewModel @Inject constructor( val presetCandidates: StateFlow> = _presetCandidates private var activePresetInteraction: PresetInteractionToken? = null private var candidatesAtOpportunity: List = emptyList() + private var selectionLoadJob: Job? = null init { - _campusList.value = emptyList() - _selectedCampus.value = null val history = AHUCache.getElectricityDepositHistory() - if (history.size == 2) { - _historyOptions.value = history - fetchCampuses() - } else { - val lastSelection = AHUCache.getRoomSelection() - if (history.isEmpty() && lastSelection != null) { - val seedLabel = normalizeLabel(lastSelection.room?.name ?: "") - if (seedLabel.isNotBlank()) { - AHUCache.saveElectricityDepositHistory( - listOf( - ElectricityDepositHistoryItem( - selection = lastSelection, - label = seedLabel, - updatedAt = System.currentTimeMillis() - ) - ) - ) - } - } - if (lastSelection != null) { - Log.d("ElectricityDepositViewModel", "选择从缓存恢复") - loadAndRestoreSelection(lastSelection) - } else { - fetchCampuses() + .filter(ElectricityDepositHistoryItem::confirmedByPayment) + .sortedByDescending(ElectricityDepositHistoryItem::updatedAt) + .take(MAX_ROOM_HISTORY) + _historyOptions.value = history + val lastSelection = AHUCache.getRoomSelection() + ?.takeIf { + isCompleteSelection(it) && + (it.controller ?: ElectricityController.C) == _selectedController.value } + ?: history.firstOrNull { + isCompleteSelection(it.selection) && + (it.selection.controller ?: ElectricityController.C) == _selectedController.value + }?.selection + if (lastSelection != null) { + Log.d("ElectricityDepositViewModel", "选择从缓存恢复") + loadAndRestoreSelection(lastSelection) + } else { + fetchInitialOptions() } viewModelScope.launch { _presetCandidates.value = behaviorRuntime.rankLocalPresets(SemanticDomain.ELECTRICITY) @@ -223,27 +224,64 @@ class ElectricityDepositViewModel @Inject constructor( } private fun loadAndRestoreSelection(selection: RoomSelectionInfo, commitPresetOnRoomRequest: Boolean = false) { - viewModelScope.launch { + selectionLoadJob?.cancel() + selectionLoadJob = viewModelScope.launch { _isLoading.value = true _errorMessage.value = null try { + val controller = selection.controller ?: ElectricityController.C + _selectedController.value = controller + AHUCache.setElectricityController(controller) _selectedCampus.value = selection.campus _selectedBuilding.value = selection.building _selectedFloor.value = selection.floor _selectedRoom.value = selection.room - - getCampus().data?.let { _campusList.value = it } ?: throw Exception("加载校区列表失败") - getBuildings().data?.let { _buildingsList.value = it } ?: throw Exception("加载楼栋列表失败") - getFloor().data?.let { _floorsList.value = it } ?: throw Exception("加载楼层列表失败") - getRoom().data?.let { _roomsList.value = it } ?: throw Exception("加载房间列表失败") - if (commitPresetOnRoomRequest) recordRoomPreset() - getRoomInfo().data?.let { + _campusList.value = listOfNotNull(selection.campus) + _buildingsList.value = listOfNotNull(selection.building) + _floorsList.value = listOfNotNull(selection.floor) + _roomsList.value = listOfNotNull(selection.room) + + // Restore the useful content first. Selector option lists are secondary and should + // not keep the whole page blocked while a remembered room balance is available. + val roomDetails = getRoomInfo() + (roomDetails.data as? RoomInfoMap)?.let { _fullRoomDetails.value = it _roomInfo.value = it.showData?.info - } ?: throw Exception("加载房间信息失败") + persistCurrentSelection() + behaviorRuntime.onContentStateChanged( + SemanticDomain.ELECTRICITY, + ContentStateBucket.READY, + freshnessBucket = 0, + resultCount = ResultCountBucket.ONE_TO_FIVE + ) + } ?: throw Exception(roomDetails.msg ?: "加载房间信息失败") + _isLoading.value = false + coroutineScope { + val initialOptionsRequest = async { getInitialOptions() } + val buildingsRequest = if (controller.requiresCampus) { + async { getBuildings() } + } else { + null + } + val floorsRequest = async { getFloor() } + val roomsRequest = async { getRoom() } + + initialOptionsRequest.await().data?.let { + if (controller.requiresCampus) { + _campusList.value = it + } else { + _buildingsList.value = it + } + } + buildingsRequest?.await()?.data?.let { _buildingsList.value = it } + floorsRequest.await().data?.let { _floorsList.value = it } + roomsRequest.await().data?.let { _roomsList.value = it } + } + if (commitPresetOnRoomRequest) recordRoomPreset() Log.d("ElectricityDepositViewModel", "从缓存恢复选择成功") - + } catch (e: CancellationException) { + throw e } catch (e: Exception) { _errorMessage.value = e.message ?: "恢复选择时发生未知错误" Log.e("ElectricityDepositViewModel", "恢复选择失败", e) @@ -254,11 +292,37 @@ class ElectricityDepositViewModel @Inject constructor( } fun selectHistory(item: ElectricityDepositHistoryItem) { - _historyOptions.value = emptyList() loadAndRestoreSelection(item.selection) } + fun deleteHistory(item: ElectricityDepositHistoryItem) { + val deletedKey = selectionKey(item.selection) + val updatedHistory = _historyOptions.value.filterNot { + selectionKey(it.selection) == deletedKey + } + _historyOptions.value = updatedHistory + AHUCache.saveElectricityDepositHistory(updatedHistory) + } + + fun onControllerSelected(controller: ElectricityController) { + selectionLoadJob?.cancel() + _selectedController.value = controller + AHUCache.setElectricityController(controller) + _campusList.value = emptyList() + _selectedCampus.value = null + _buildingsList.value = emptyList() + _selectedBuilding.value = null + _floorsList.value = emptyList() + _selectedFloor.value = null + _roomsList.value = emptyList() + _selectedRoom.value = null + _fullRoomDetails.value = null + _roomInfo.value = null + fetchInitialOptions() + } + fun onCampusSelected(campus: CampusDataItem) { + selectionLoadJob?.cancel() _selectedCampus.value = campus _buildingsList.value = emptyList() _selectedBuilding.value = null @@ -266,69 +330,100 @@ class ElectricityDepositViewModel @Inject constructor( _selectedFloor.value = null _roomsList.value = emptyList() _selectedRoom.value = null + _fullRoomDetails.value = null _roomInfo.value = null fetchBuildings() } fun onBuildingSelected(building: CampusDataItem) { + selectionLoadJob?.cancel() _selectedBuilding.value = building _floorsList.value = emptyList() _selectedFloor.value = null _roomsList.value = emptyList() _selectedRoom.value = null + _fullRoomDetails.value = null _roomInfo.value = null fetchFloor() } fun onfloorSelected(floor: CampusDataItem) { + selectionLoadJob?.cancel() _selectedFloor.value = floor _roomsList.value = emptyList() _selectedRoom.value = null + _fullRoomDetails.value = null _roomInfo.value = null fetchRoom() } fun onRoomSelected(room: CampusDataItem) { + selectionLoadJob?.cancel() _selectedRoom.value = room + _fullRoomDetails.value = null _roomInfo.value = null fetchRoomInfo() } - private fun fetchCampuses() { - viewModelScope.launch { + fun retry() { + when { + _selectedController.value.requiresCampus && _selectedCampus.value == null -> fetchInitialOptions() + _selectedBuilding.value == null && _selectedController.value.requiresCampus -> fetchBuildings() + _selectedBuilding.value == null -> fetchInitialOptions() + _selectedFloor.value == null -> fetchFloor() + _selectedRoom.value == null -> fetchRoom() + else -> fetchRoomInfo() + } + } + + private fun fetchInitialOptions() { + selectionLoadJob = viewModelScope.launch { _isLoading.value = true _errorMessage.value = null try { - val response = getCampus() + val response = getInitialOptions() if (response.code == 0 && response.data != null) { - _campusList.value = response.data!! + val items = response.data!! + if (_selectedController.value.requiresCampus) { + _campusList.value = items + if (_selectedCampus.value == null) { + items.firstOrNull()?.let { first -> + _selectedCampus.value = first + fetchBuildings() + } + } + } else { + _buildingsList.value = items + } } else { - _errorMessage.value = response.msg ?: "加载校区失败" + _errorMessage.value = response.msg ?: "加载电控选项失败" } } catch (e: Exception) { _errorMessage.value = "网络错误: ${e.message}" } finally { - _isLoading.value = false + if (_errorMessage.value != null || _selectedCampus.value == null) { + _isLoading.value = false + } } } } - private suspend fun getCampus(): AHUResponse> { + private suspend fun getInitialOptions(): AHUResponse> { val responseWrapper = AHUResponse>() val formBody = FormBody.Builder() - .add("feeitemid", "488") + .add("feeitemid", _selectedController.value.feeItemId) .add("type", "select") .add("level", "0") .build() try { - val res = YcardApi.API.getFeeItemThirdData(formBody) - Log.d("ElectricityDepositViewModel", "getCampus响应码: ${res.code()}") + val res = YcardApi.authorizedCall { getFeeItemThirdData(formBody) } + Log.d("ElectricityDepositViewModel", "getInitialOptions响应码: ${res.code()}") val responseBody = res.body()?.string() if (res.isSuccessful) { if (responseBody.isNullOrEmpty()) { responseWrapper.code = -1 responseWrapper.msg = "服务器返回内容为空" - Log.e("ElectricityDepositViewModel", "getCampus Error: Server returned empty body") + Log.e("ElectricityDepositViewModel", "getInitialOptions Error: Server returned empty body") return responseWrapper } val parsedResponse = Gson().fromJson(responseBody, CampusApiResponse::class.java) @@ -336,21 +431,21 @@ class ElectricityDepositViewModel @Inject constructor( responseWrapper.code = 0 responseWrapper.msg = "success" responseWrapper.data = parsedResponse.map.data - Log.d("ElectricityDepositViewModel", "getCampus Success: Loaded ${parsedResponse.map.data.size} items") + Log.d("ElectricityDepositViewModel", "getInitialOptions Success: Loaded ${parsedResponse.map.data.size} items") } else { responseWrapper.code = -1 - responseWrapper.msg = "解析数据失败,未找到校区列表" - Log.e("ElectricityDepositViewModel", "getCampus Parse Error: map.data is null") + responseWrapper.msg = "解析数据失败,未找到电控选项" + Log.e("ElectricityDepositViewModel", "getInitialOptions Parse Error: map.data is null") } } else { responseWrapper.code = res.code() responseWrapper.msg = "请求接口失败: ${res.message()}" - Log.e("ElectricityDepositViewModel", "getCampus Network Error: ${res.code()} ${res.message()}") + Log.e("ElectricityDepositViewModel", "getInitialOptions Network Error: ${res.code()} ${res.message()}") } } catch (e: Exception) { responseWrapper.code = -1 responseWrapper.msg = "发生未知错误: ${e.message}" - Log.e("ElectricityDepositViewModel", "getCampus Exception", e) + Log.e("ElectricityDepositViewModel", "getInitialOptions Exception", e) } return responseWrapper } @@ -361,20 +456,23 @@ class ElectricityDepositViewModel @Inject constructor( return } - viewModelScope.launch { + selectionLoadJob = viewModelScope.launch { _isLoading.value = true _errorMessage.value = null try { val response = getBuildings() if (response.code == 0 && response.data != null) { - _buildingsList.value = response.data!! + val items = response.data!! + _buildingsList.value = items } else { _errorMessage.value = response.msg ?: "加载楼栋失败" } } catch (e: Exception) { _errorMessage.value = "网络错误: ${e.message}" } finally { - _isLoading.value = false + if (_errorMessage.value != null || _selectedBuilding.value == null) { + _isLoading.value = false + } } } } @@ -387,17 +485,15 @@ class ElectricityDepositViewModel @Inject constructor( return responseWrapper } - _isLoading.value = true - _errorMessage.value = null val formBody = FormBody.Builder() - .add("feeitemid", "488") + .add("feeitemid", _selectedController.value.feeItemId) .add("type", "select") .add("level", "1") .add("campus", selectedCampusValue) .build() try { - val res = YcardApi.API.getFeeItemThirdData(formBody) + val res = YcardApi.authorizedCall { getFeeItemThirdData(formBody) } Log.d("ElectricityDepositViewModel", "getBuildings响应码: ${res.code()}") val responseBody = res.body()?.string() if (res.isSuccessful) { @@ -422,8 +518,6 @@ class ElectricityDepositViewModel @Inject constructor( } catch (e: Exception) { responseWrapper.code = -1 responseWrapper.msg = "发生未知错误: ${e.message}" - } finally { - _isLoading.value = false } return responseWrapper } @@ -434,27 +528,32 @@ class ElectricityDepositViewModel @Inject constructor( return } - viewModelScope.launch { + selectionLoadJob = viewModelScope.launch { _isLoading.value = true _errorMessage.value = null try { val response = getFloor() if (response.code == 0 && response.data != null) { - _floorsList.value = response.data!! + val items = response.data!! + _floorsList.value = items } else { _errorMessage.value = response.msg ?: "加载楼层失败" } } catch (e: Exception) { _errorMessage.value = "网络错误: ${e.message}" } finally { - _isLoading.value = false + if (_errorMessage.value != null || _selectedFloor.value == null) { + _isLoading.value = false + } } } } private suspend fun getFloor(): AHUResponse> { val responseWrapper = AHUResponse>() - val selectedCampusValue = _selectedCampus.value?.value ?: run { + val controller = _selectedController.value + val selectedCampusValue = _selectedCampus.value?.value + if (controller.requiresCampus && selectedCampusValue == null) { responseWrapper.code = -1 responseWrapper.msg = "selectedCampusValue内容为空" return responseWrapper @@ -465,16 +564,17 @@ class ElectricityDepositViewModel @Inject constructor( return responseWrapper } - val formBody = FormBody.Builder() - .add("feeitemid", "488") + val formBuilder = FormBody.Builder() + .add("feeitemid", controller.feeItemId) .add("type", "select") - .add("level", "2") - .add("campus", selectedCampusValue) + .add("level", controller.floorLevel) + if (selectedCampusValue != null) formBuilder.add("campus", selectedCampusValue) + val formBody = formBuilder .add("building", selectedBuildingValue) .build() try { - val res = YcardApi.API.getFeeItemThirdData(formBody) + val res = YcardApi.authorizedCall { getFeeItemThirdData(formBody) } Log.d("ElectricityDepositViewModel", "getFloor响应码: ${res.code()}") val responseBody = res.body()?.string() if (res.isSuccessful) { @@ -509,32 +609,37 @@ class ElectricityDepositViewModel @Inject constructor( return } - viewModelScope.launch { + selectionLoadJob = viewModelScope.launch { _isLoading.value = true _errorMessage.value = null try { val response = getRoom() if (response.code == 0 && response.data != null) { - _roomsList.value = response.data!! + val items = response.data!! + _roomsList.value = items } else { _errorMessage.value = response.msg ?: "加载房间失败" } } catch (e: Exception) { _errorMessage.value = "网络错误: ${e.message}" } finally { - _isLoading.value = false + if (_errorMessage.value != null || _selectedRoom.value == null) { + _isLoading.value = false + } } } } private suspend fun getRoom(): AHUResponse> { val responseWrapper = AHUResponse>() + val controller = _selectedController.value val selectedFloorValue = _selectedFloor.value?.value ?: run { responseWrapper.code = -1 responseWrapper.msg = "selectedFloorValue内容为空" return responseWrapper } - val selectedCampusValue = _selectedCampus.value?.value ?: run { + val selectedCampusValue = _selectedCampus.value?.value + if (controller.requiresCampus && selectedCampusValue == null) { responseWrapper.code = -1 responseWrapper.msg = "_selectedCampus内容为空" return responseWrapper @@ -545,17 +650,18 @@ class ElectricityDepositViewModel @Inject constructor( return responseWrapper } - val formBody = FormBody.Builder() - .add("feeitemid", "488") + val formBuilder = FormBody.Builder() + .add("feeitemid", controller.feeItemId) .add("type", "select") - .add("level", "3") - .add("campus", selectedCampusValue) + .add("level", controller.roomLevel) + if (selectedCampusValue != null) formBuilder.add("campus", selectedCampusValue) + val formBody = formBuilder .add("building", selectedBuildingValue) .add("floor", selectedFloorValue) .build() try { - val res = YcardApi.API.getFeeItemThirdData(formBody) + val res = YcardApi.authorizedCall { getFeeItemThirdData(formBody) } Log.d("ElectricityDepositViewModel", "getRoom响应码: ${res.code()}") val responseBody = res.body()?.string() if (res.isSuccessful) { @@ -590,21 +696,22 @@ class ElectricityDepositViewModel @Inject constructor( return } - viewModelScope.launch { + selectionLoadJob = viewModelScope.launch { _isLoading.value = true _errorMessage.value = null try { - recordRoomPreset() val response = getRoomInfo() if (response.code == 0 && response.data != null) { _fullRoomDetails.value = response.data _roomInfo.value = response.data.showData?.info + persistCurrentSelection() behaviorRuntime.onContentStateChanged( SemanticDomain.ELECTRICITY, ContentStateBucket.READY, freshnessBucket = 0, resultCount = ResultCountBucket.ONE_TO_FIVE ) + launch { recordRoomPreset() } } else { _errorMessage.value = response.msg ?: "加载房间信息失败" reportRoomError() @@ -624,7 +731,13 @@ class ElectricityDepositViewModel @Inject constructor( candidatesAtOpportunity = _presetCandidates.value val selection = runCatching { Gson().fromJson(applied.localPayloadJson, RoomSelectionInfo::class.java) }.getOrNull() ?: return@launch - if (selection.campus == null || selection.building == null || selection.floor == null || selection.room == null) { + val controller = selection.controller ?: ElectricityController.C + if ( + selection.building == null || + selection.floor == null || + selection.room == null || + controller.requiresCampus && selection.campus == null + ) { return@launch } _presetCandidates.value = emptyList() @@ -636,18 +749,27 @@ class ElectricityDepositViewModel @Inject constructor( campus = _selectedCampus.value, building = _selectedBuilding.value, floor = _selectedFloor.value, - room = _selectedRoom.value + room = _selectedRoom.value, + controller = _selectedController.value ) - val campus = selection.campus ?: return + val controller = selection.controller ?: ElectricityController.C + val campus = selection.campus val building = selection.building ?: return val floor = selection.floor ?: return val room = selection.room ?: return + if (controller.requiresCampus && campus == null) return behaviorRuntime.recordNaturalPresetSubmission( PresetSubmission( SemanticDomain.ELECTRICITY, Gson().toJson(selection), "{\"roomCategory\":\"RECENT_LOCAL_ROOM\"}", - "${campus.value}|${building.value}|${floor.value}|${room.value}" + listOf( + controller.name, + campus?.value.orEmpty(), + building.value, + floor.value, + room.value + ).joinToString("|") ), interactionToken = activePresetInteraction, candidatesAtOpportunity = candidatesAtOpportunity.ifEmpty { _presetCandidates.value } @@ -686,6 +808,7 @@ class ElectricityDepositViewModel @Inject constructor( private suspend fun getRoomInfo(): AHUResponse { val responseWrapper = AHUResponse() + val controller = _selectedController.value val selectedRoomValue = _selectedRoom.value?.value ?: run { responseWrapper.code = -1 @@ -702,24 +825,26 @@ class ElectricityDepositViewModel @Inject constructor( responseWrapper.msg = "selectedBuildingValue内容为空" return responseWrapper } - val selectedCampusValue = _selectedCampus.value?.value ?: run { + val selectedCampusValue = _selectedCampus.value?.value + if (controller.requiresCampus && selectedCampusValue == null) { responseWrapper.code = -1 responseWrapper.msg = "selectedCampusValue内容为空" return responseWrapper } - val formBody = FormBody.Builder() - .add("feeitemid", "488") + val formBuilder = FormBody.Builder() + .add("feeitemid", controller.feeItemId) .add("type", "IEC") - .add("level", "4") - .add("campus", selectedCampusValue) + .add("level", controller.roomInfoLevel) + if (selectedCampusValue != null) formBuilder.add("campus", selectedCampusValue) + val formBody = formBuilder .add("building", selectedBuildingValue) .add("floor", selectedFloorValue) .add("room", selectedRoomValue) .build() try { - val res = YcardApi.API.getFeeItemThirdData(formBody) + val res = YcardApi.authorizedCall { getFeeItemThirdData(formBody) } Log.d("ElectricityDepositViewModel", "getRoomInfo响应码: ${res.code()}") val responseBody = res.body()?.string() if (res.isSuccessful) { @@ -772,7 +897,7 @@ class ElectricityDepositViewModel @Inject constructor( val thirdPartyJson = Gson().toJson(paymentData) val formBody = buildSignedFormBody( linkedMapOf( - "feeitemid" to "488", + "feeitemid" to _selectedController.value.feeItemId, "tranamt" to amount, "flag" to "choose", "source" to "app", @@ -783,7 +908,7 @@ class ElectricityDepositViewModel @Inject constructor( ) ) try { - val res = YcardApi.API.pay(formBody) + val res = YcardApi.authorizedCall { pay(formBody) } Log.d("ElectricityDepositViewModel", "getPaymentOrder响应码: ${res.code()}") val responseBody = res.body()?.string() if (res.isSuccessful) { @@ -824,7 +949,7 @@ class ElectricityDepositViewModel @Inject constructor( ) try { - val res = YcardApi.API.pay(formBody) + val res = YcardApi.authorizedCall { pay(formBody) } Log.d("ElectricityDepositViewModel", "getAccountPayInfo响应码: ${res.code()}") val responseBody = res.body()?.string() if (res.isSuccessful) { @@ -918,7 +1043,7 @@ class ElectricityDepositViewModel @Inject constructor( ) Log.d("ElectricityDepositViewModel", "开始执行最终支付请求...") - val finalRes = YcardApi.API.pay(finalFormBody) + val finalRes = YcardApi.authorizedCall { pay(finalFormBody) } Log.d("ElectricityDepositViewModel", "最终支付请求完成,响应码: ${finalRes.code()}") val responseBody = finalRes.body()?.string() @@ -950,20 +1075,19 @@ class ElectricityDepositViewModel @Inject constructor( campus = _selectedCampus.value, building = _selectedBuilding.value, floor = _selectedFloor.value, - room = _selectedRoom.value + room = _selectedRoom.value, + controller = _selectedController.value ) - saveRoomSelection(roomSelectionInfo) - - val label = normalizeLabel(_fullRoomDetails.value?.data?.roomName ?: _selectedRoom.value?.name ?: "") - val newItem = ElectricityDepositHistoryItem( + persistCurrentSelection( selection = roomSelectionInfo, - label = label, - updatedAt = System.currentTimeMillis() + confirmedByPayment = true ) - val existingHistory = AHUCache.getElectricityDepositHistory() - val key = selectionKey(roomSelectionInfo) - val updatedHistory = (listOf(newItem) + existingHistory.filter { selectionKey(it.selection) != key }).take(2) - AHUCache.saveElectricityDepositHistory(updatedHistory) + delay(1_000L) + val refreshedInfo = getRoomInfo() + if (refreshedInfo.code == 0 && refreshedInfo.data != null) { + _fullRoomDetails.value = refreshedInfo.data + _roomInfo.value = refreshedInfo.data.showData?.info + } } else { val errorMessage = parsedResponse.msg ?: "支付失败,未知错误" _errorMessage.value = errorMessage @@ -1018,8 +1142,52 @@ class ElectricityDepositViewModel @Inject constructor( return builder.build() } + private fun isCompleteSelection(selection: RoomSelectionInfo): Boolean { + val controller = selection.controller ?: ElectricityController.C + return (!controller.requiresCampus || selection.campus != null) && + selection.building != null && + selection.floor != null && + selection.room != null + } + + private fun persistCurrentSelection( + selection: RoomSelectionInfo = RoomSelectionInfo( + campus = _selectedCampus.value, + building = _selectedBuilding.value, + floor = _selectedFloor.value, + room = _selectedRoom.value, + controller = _selectedController.value + ), + confirmedByPayment: Boolean = false + ) { + if (!isCompleteSelection(selection)) return + + saveRoomSelection(selection) + if (!confirmedByPayment) return + val roomLabel = normalizeLabel( + _fullRoomDetails.value?.data?.roomName ?: selection.room?.name.orEmpty() + ) + if (roomLabel.isBlank()) return + val controller = selection.controller ?: ElectricityController.C + val label = "${controller.displayName} · $roomLabel" + + val item = ElectricityDepositHistoryItem( + selection = selection, + label = label, + updatedAt = System.currentTimeMillis(), + confirmedByPayment = true + ) + val key = selectionKey(selection) + val updatedHistory = (listOf(item) + _historyOptions.value.filter { + selectionKey(it.selection) != key + }).take(MAX_ROOM_HISTORY) + _historyOptions.value = updatedHistory + AHUCache.saveElectricityDepositHistory(updatedHistory) + } + private fun selectionKey(selection: RoomSelectionInfo): String { return listOf( + (selection.controller ?: ElectricityController.C).name, selection.campus?.value, selection.building?.value, selection.floor?.value, @@ -1035,4 +1203,8 @@ class ElectricityDepositViewModel @Inject constructor( val parts = value.split(Regex("\\s+")).filter { it.isNotBlank() } return if (parts.isEmpty()) "" else parts.last() } + + private companion object { + const val MAX_ROOM_HISTORY = 12 + } } diff --git a/app/src/main/java/com/ahu/ahutong/ui/state/EvaluationViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/state/EvaluationViewModel.kt index ffce3d6c..d05a4dd9 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/state/EvaluationViewModel.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/state/EvaluationViewModel.kt @@ -16,6 +16,7 @@ import com.ahu.ahutong.data.model.EvalTask import com.ahu.ahutong.data.model.EvalTaskItem import com.ahu.ahutong.data.model.EvalTeacher import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.Job import kotlinx.coroutines.launch class EvaluationViewModel : ViewModel() { @@ -42,24 +43,34 @@ class EvaluationViewModel : ViewModel() { val presetQuestions = MutableStateFlow>(emptyList()) val isPresetLoading = MutableStateFlow(false) val presetActionMessage = MutableStateFlow(null) + private var listLoadJob: Job? = null fun loadSemesters() { - viewModelScope.launch { + listLoadJob?.cancel() + listLoadJob = viewModelScope.launch { isLoading.value = true errorMessage.value = null - EvaluationRepository.getSemesters() - .onSuccess { items -> - semesters.value = items - if (selectedSemesterId.value.isEmpty() && items.isNotEmpty()) { - val currentSemesterId = EvaluationRepository.getCurrentSemesterId() - selectedSemesterId.value = items.firstOrNull { - it.id == currentSemesterId - }?.id ?: items.first().id - } - loadEvaluationList() + try { + val items = EvaluationRepository.getSemesters().getOrElse { + errorMessage.value = it.message ?: "加载学期失败" + return@launch } - .onFailure { errorMessage.value = it.message ?: "加载学期失败" } - isLoading.value = false + semesters.value = items + if (selectedSemesterId.value.isEmpty() && items.isNotEmpty()) { + val currentSemesterId = EvaluationRepository.getCurrentSemesterId() + selectedSemesterId.value = items.firstOrNull { + it.id == currentSemesterId + }?.id ?: items.first().id + } + val semesterId = selectedSemesterId.value + if (semesterId.isNotEmpty()) { + EvaluationRepository.getEvaluationList(semesterId) + .onSuccess { taskItems.value = it } + .onFailure { errorMessage.value = it.message ?: "加载评教列表失败" } + } + } finally { + isLoading.value = false + } } } @@ -67,13 +78,17 @@ class EvaluationViewModel : ViewModel() { val semesterId = selectedSemesterId.value if (semesterId.isEmpty()) return - viewModelScope.launch { + listLoadJob?.cancel() + listLoadJob = viewModelScope.launch { isLoading.value = true errorMessage.value = null - EvaluationRepository.getEvaluationList(semesterId) - .onSuccess { taskItems.value = it } - .onFailure { errorMessage.value = it.message ?: "加载评教列表失败" } - isLoading.value = false + try { + EvaluationRepository.getEvaluationList(semesterId) + .onSuccess { taskItems.value = it } + .onFailure { errorMessage.value = it.message ?: "加载评教列表失败" } + } finally { + isLoading.value = false + } } } diff --git a/app/src/main/java/com/ahu/ahutong/ui/state/ExamViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/state/ExamViewModel.kt index 78d9dcc9..855d4392 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/state/ExamViewModel.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/state/ExamViewModel.kt @@ -7,7 +7,6 @@ import com.ahu.ahutong.data.AHURepository import com.ahu.ahutong.data.dao.AHUCache import com.ahu.ahutong.data.model.Exam import com.ahu.ahutong.ext.launchSafe -import com.google.gson.Gson import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -16,6 +15,29 @@ import kotlinx.coroutines.launch enum class RefreshState { IDLE, LOADING, UPDATED } +internal object ExamRefreshPolicy { + const val AUTO_REFRESH_INTERVAL_MS = 5 * 60 * 1_000L + + fun shouldRefresh(cachedAtMillis: Long, nowMillis: Long = System.currentTimeMillis()): Boolean { + return cachedAtMillis <= 0L || + nowMillis < cachedAtMillis || + nowMillis - cachedAtMillis >= AUTO_REFRESH_INTERVAL_MS + } +} + +internal fun List.hasSameExamContents(other: List): Boolean { + if (size != other.size) return false + return indices.all { index -> + val left = this[index] + val right = other[index] + left.course == right.course && + left.location == right.location && + left.time == right.time && + left.seatNum == right.seatNum && + left.finished == right.finished + } +} + class ExamViewModel : ViewModel() { val data = MutableLiveData>>() val isLoading = MutableStateFlow(null) @@ -28,12 +50,15 @@ class ExamViewModel : ViewModel() { private var refreshJob: Job? = null fun loadExam(isRefresh: Boolean = false) { + if (refreshJob?.isActive == true) { + if (!isRefresh) return + refreshJob?.cancel() + } // 正在刷新中则忽略新请求 if (_refreshState.value == RefreshState.LOADING) return // 首次自动后台加载也跳过重复 if (!isRefresh && isLoading.value == true) return - refreshJob?.cancel() refreshJob = viewModelScope.launchSafe { val user = AHUCache.getCurrentUser() if (user == null && !AHUCache.getMockData()) { @@ -44,19 +69,25 @@ class ExamViewModel : ViewModel() { // 1. 优先展示缓存数据,首屏秒出 val cached = AHUCache.getExamInfo().orEmpty() - if (cached.isNotEmpty() && !isRefresh) { + val cachedAt = AHUCache.getExamInfoUpdatedAt() + val hasCachedSnapshot = cachedAt > 0L + if (!isRefresh && (cached.isNotEmpty() || hasCachedSnapshot)) { data.value = Result.success(cached) } - // 手动刷新时:先显示 LOADING,保证最少 1 秒可见 + if (!isRefresh && !ExamRefreshPolicy.shouldRefresh(cachedAt)) { + isLoading.value = false + errorMessage.value = null + return@launchSafe + } + + // Refresh feedback starts immediately; never delay the actual request for animation. if (isRefresh) { _refreshState.value = RefreshState.LOADING - // 最小加载时间 1 秒,避免一闪而过 - delay(800) } // 仅无缓存时显示全屏加载动画 - if (cached.isEmpty()) { + if (!hasCachedSnapshot && cached.isEmpty()) { isLoading.value = true } errorMessage.value = null @@ -71,18 +102,19 @@ class ExamViewModel : ViewModel() { val newExams = result.getOrNull().orEmpty() // 与缓存比对,有差异才更新 UI - val cachedJson = Gson().toJson(cached) - val newJson = Gson().toJson(newExams) - if (cachedJson != newJson) { - AHUCache.saveExamInfo(newExams) + if (!hasCachedSnapshot || !cached.hasSameExamContents(newExams)) { data.value = Result.success(newExams) } - // 手动刷新后显示"已更新",最少 2 秒 + // Keep acknowledgement visible without holding up data delivery or navigation. if (isRefresh) { _refreshState.value = RefreshState.UPDATED - delay(2000) - _refreshState.value = RefreshState.IDLE + viewModelScope.launch { + delay(700) + if (_refreshState.value == RefreshState.UPDATED) { + _refreshState.value = RefreshState.IDLE + } + } } } else { // 网络失败:手动刷新时立即恢复 IDLE diff --git a/app/src/main/java/com/ahu/ahutong/ui/state/FreeClassroomViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/state/FreeClassroomViewModel.kt index dde7b2a1..d64d1b76 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/state/FreeClassroomViewModel.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/state/FreeClassroomViewModel.kt @@ -40,6 +40,7 @@ class FreeClassroomViewModel @Inject constructor( val endDate = MutableStateFlow(LocalDate.now()) val isLoadingBuildings = MutableStateFlow(false) val isSearching = MutableStateFlow(false) + val hasSearched = MutableStateFlow(false) val freeRooms = MutableStateFlow>(emptyList()) val errorMessage = MutableStateFlow(null) val presetCandidates = MutableStateFlow>(emptyList()) @@ -59,6 +60,8 @@ class FreeClassroomViewModel @Inject constructor( selectedCampusId.value = campusId selectedBuildingIds.value = emptySet() freeRooms.value = emptyList() + hasSearched.value = false + errorMessage.value = null loadBuildings(campusId) } @@ -74,23 +77,50 @@ class FreeClassroomViewModel @Inject constructor( } fun toggleBuilding(buildingId: Int) { + errorMessage.value = null selectedBuildingIds.value = selectedBuildingIds.value.toMutableSet().apply { if (contains(buildingId)) remove(buildingId) else add(buildingId) } } + fun selectBuilding(buildingId: Int?) { + selectedBuildingIds.value = buildingId?.let(::setOf).orEmpty() + errorMessage.value = null + } + fun toggleUnit(unit: Int) { + errorMessage.value = null selectedUnits.value = selectedUnits.value.toMutableSet().apply { if (contains(unit)) remove(unit) else add(unit) } } fun toggleUnitsRange(start: Int, end: Int) { + errorMessage.value = null val range = (start..end).toSet() val current = selectedUnits.value selectedUnits.value = if (range.all { it in current }) current - range else current + range } + fun selectAllBuildings() { + selectedBuildingIds.value = emptySet() + errorMessage.value = null + } + + fun selectAllUnits() { + selectedUnits.value = emptySet() + errorMessage.value = null + } + + fun selectUnitRange(range: IntRange) { + selectedUnits.value = range.filter { it in 1..13 }.toSet() + errorMessage.value = null + } + + fun clearError() { + errorMessage.value = null + } + fun setDateRange(start: LocalDate, end: LocalDate) { startDate.value = start endDate.value = end @@ -120,30 +150,26 @@ class FreeClassroomViewModel @Inject constructor( errorMessage.value = "当前校区暂无教学楼数据" return@launchSafe } - val buildingIds = if (selectedBuildingIds.value.isEmpty()) { - allBuildings.map { it.id } - } else { - selectedBuildingIds.value.toList() - } - val units = if (selectedUnits.value.isEmpty()) { - (1..13).map { it.toString() } - } else { - selectedUnits.value.sorted().map { it.toString() } - } + val selectedBuildings = selectedBuildingIds.value + val buildingQueries = freeClassroomBuildingQueries(selectedBuildings) + val units = freeClassroomUnits(selectedUnits.value) val start = startDate.value.toString() val end = endDate.value.toString() isSearching.value = true + hasSearched.value = true errorMessage.value = null - recordDispatchedPreset(campusId, buildingIds, units) + recordDispatchedPreset(campusId, selectedBuildings.toList(), units) runCatching { val allRooms = if (AHUCache.getMockData()) { - MockCampusData.freeRooms(campusId, buildingIds) + val mockBuildingIds = selectedBuildings.ifEmpty { + allBuildings.mapTo(mutableSetOf()) { it.id } + } + MockCampusData.freeRooms(campusId, mockBuildingIds.toList()) } else { - val remoteRooms = mutableListOf() - buildingIds.forEach { buildingId -> + buildingQueries.flatMap { buildingId -> val response = JwxtApi.API.getFreeRooms( GetFreeRoomsRequest( - buildingId = buildingId.toString(), + buildingId = buildingId, campusId = campusId.toString(), dateTimeSegmentCmd = DateTimeSegmentCmd( startDateTime = start, @@ -152,9 +178,8 @@ class FreeClassroomViewModel @Inject constructor( ) ) ) - remoteRooms += response.roomList + response.roomList } - remoteRooms } freeRooms.value = allRooms .distinctBy { "${it.id}-${it.building.id}" } @@ -188,7 +213,10 @@ class FreeClassroomViewModel @Inject constructor( selectedCampusId.value = decoded.campusId selectedBuildingIds.value = emptySet() loadBuildings(decoded.campusId) - selectedBuildingIds.value = decoded.buildingIds.toSet().intersect(buildings.value.map { it.id }.toSet()) + selectedBuildingIds.value = decoded.buildingIds + .firstOrNull { candidate -> buildings.value.any { it.id == candidate } } + ?.let(::setOf) + .orEmpty() selectedUnits.value = decoded.units.toSet().filter { it in 1..13 }.toSet() val start = runCatching { LocalDate.parse(decoded.startDate) }.getOrNull() ?: return@launchSafe val end = runCatching { LocalDate.parse(decoded.endDate) }.getOrNull() ?: return@launchSafe @@ -288,6 +316,13 @@ class FreeClassroomViewModel @Inject constructor( } } +internal fun freeClassroomBuildingQueries(selectedBuildingIds: Set): List = + selectedBuildingIds.sorted().map(Int::toString).ifEmpty { listOf("") } + +internal fun freeClassroomUnits(selectedUnits: Set): List = + selectedUnits.filter { it in 1..13 }.sorted().map(Int::toString) + .ifEmpty { (1..13).map(Int::toString) } + data class CampusOption( val id: Int, val name: String diff --git a/app/src/main/java/com/ahu/ahutong/ui/state/LicenseViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/state/LicenseViewModel.kt index 8a24d431..172e271b 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/state/LicenseViewModel.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/state/LicenseViewModel.kt @@ -24,6 +24,12 @@ class LicenseViewModel : ViewModel() { "https://source.android.com", "Apache Software License 2.0" ), + License( + "Miuix", + "compose-miuix-ui contributors", + "https://github.com/compose-miuix-ui/miuix", + "Apache License 2.0" + ), License( "Gson", "Google", diff --git a/app/src/main/java/com/ahu/ahutong/ui/state/LostFoundViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/state/LostFoundViewModel.kt index 800cc7a4..030bf47e 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/state/LostFoundViewModel.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/state/LostFoundViewModel.kt @@ -23,6 +23,8 @@ import com.google.gson.Gson import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -54,6 +56,7 @@ class LostFoundViewModel @Inject constructor( var presetCandidates by mutableStateOf>(emptyList()) private set private var filterCommitJob: Job? = null + private var listRequestJob: Job? = null private var activePresetInteraction: PresetInteractionToken? = null private var candidatesAtOpportunity: List = emptyList() @@ -76,6 +79,17 @@ class LostFoundViewModel @Inject constructor( var errorMessage by mutableStateOf(null) + var myPosts by mutableStateOf>(emptyList()) + private set + var myPostsLoading by mutableStateOf(false) + private set + var myPostsError by mutableStateOf(null) + private set + var isPublishing by mutableStateOf(false) + private set + var deletingPostIds by mutableStateOf>(emptySet()) + private set + /** * 是否还有更多数据 */ @@ -197,58 +211,57 @@ class LostFoundViewModel @Inject constructor( AHUCache.getLostFoundList(state) } - scheduleFilterQuery() + fetchFirstPage(commitPresetOnDispatch = true) } fun selectCampusFilter(campusId: String?) { if (selectedCampus == campusId) return selectedCampus = campusId - scheduleFilterQuery() + scheduleFilterCommit() } fun selectTypeFilter(typeId: String?) { if (selectedType == typeId) return selectedType = typeId - scheduleFilterQuery() + scheduleFilterCommit() } /** * 获取第一页(覆盖) */ - fun fetchFirstPage(commitPresetOnDispatch: Boolean = false) = viewModelScope.launch { - listLoading = true - try { - if (commitPresetOnDispatch) recordCurrentPresetDispatch() - val result = AHURepository.getLostFoundList( - pageNo = 1, - pageSize = pageSize, - state = currentState - ) - if (result.code == 0) { - val pageData = result.data.data - - currentPage = pageData.pageNum - totalPages = pageData.pages - - lostFoundList = pageData.list - - // 覆盖缓存 - AHUCache.saveLostFoundList( - currentState, - pageData.list + fun fetchFirstPage(commitPresetOnDispatch: Boolean = false) { + listRequestJob?.cancel() + val requestedState = currentState + listRequestJob = viewModelScope.launch { + listLoading = true + try { + if (commitPresetOnDispatch) recordCurrentPresetDispatch() + val result = AHURepository.getLostFoundList( + pageNo = 1, + pageSize = pageSize, + state = requestedState ) - - errorMessage = null - reportListContent(pageData.list.size, fresh = true) - } else { - errorMessage = result.msg - reportListError() + if (currentState != requestedState) return@launch + if (result.code == 0) { + val pageData = result.data.data + currentPage = pageData.pageNum + totalPages = pageData.pages + lostFoundList = pageData.list + AHUCache.saveLostFoundList(requestedState, pageData.list) + errorMessage = null + reportListContent(pageData.list.size, fresh = true) + } else { + errorMessage = result.msg + reportListError() + } + } catch (t: Throwable) { + if (currentState == requestedState) { + errorMessage = t.message ?: "获取列表失败" + reportListError() + } + } finally { + if (currentState == requestedState) listLoading = false } - } catch (t: Throwable) { - errorMessage = t.message ?: "获取列表失败" - reportListError() - } finally { - listLoading = false } } @@ -256,6 +269,8 @@ class LostFoundViewModel @Inject constructor( * 刷新 */ fun refreshList() { + listRequestJob?.cancel() + val requestedState = currentState viewModelScope.launch { isRefreshing = true @@ -267,9 +282,10 @@ class LostFoundViewModel @Inject constructor( AHURepository.getLostFoundList( pageNo = 1, pageSize = pageSize, - state = currentState + state = requestedState ) + if (currentState != requestedState) return@launch if (result.code == 0) { val pageData = result.data.data @@ -284,11 +300,11 @@ class LostFoundViewModel @Inject constructor( pageData.list AHUCache.clearLostFoundList( - currentState + requestedState ) AHUCache.saveLostFoundList( - currentState, + requestedState, pageData.list ) @@ -316,15 +332,17 @@ class LostFoundViewModel @Inject constructor( viewModelScope.launch { isLoadingMore = true + val requestedState = currentState try { val nextPage = currentPage + 1 val result = AHURepository.getLostFoundList( pageNo = nextPage, pageSize = pageSize, - state = currentState + state = requestedState ) + if (currentState != requestedState) return@launch if (result.code == 0) { val pageData = result.data.data @@ -336,7 +354,7 @@ class LostFoundViewModel @Inject constructor( lostFoundList = lostFoundList + newList AHUCache.appendLostFoundList( - currentState, + requestedState, newList ) @@ -362,47 +380,100 @@ class LostFoundViewModel @Inject constructor( num1: String, campusId: String, typeId: String, - state: String + state: String, + onResult: (Result) -> Unit = {} ) { + if (isPublishing) return viewModelScope.launch { - AHURepository.publishLostFound( - LostFoundPublishRequest( - imgs = emptyList(), - linkman = linkman, - phone = phone, - typeid = typeId, - num1 = num1, - campusid = campusId, - title = title, - state = state, - auditresult = 1 + isPublishing = true + val result = runCatching { + val response = AHURepository.publishLostFound( + LostFoundPublishRequest( + imgs = emptyList(), + linkman = linkman, + phone = phone, + typeid = typeId, + num1 = num1, + campusid = campusId, + title = title, + state = state, + auditresult = 1 + ) ) - ) - - refreshList() + check(response.isSuccessful) { response.msg ?: "发布失败" } + } + if (result.isSuccess) { + refreshList() + loadMyPosts() + } + isPublishing = false + onResult(result) } } fun deleteLostFound( - id: String + id: String, + onResult: (Result) -> Unit = {} ) { + if (id in deletingPostIds) return viewModelScope.launch { - try { - val result = - AHURepository.deleteLostFound(id) + deletingPostIds = deletingPostIds + id + val result = runCatching { + val response = AHURepository.deleteLostFound(id) + check(response.isSuccessful) { response.msg ?: "删除失败" } + } + if (result.isSuccess) { + lostFoundList = lostFoundList.filterNot { it.id == id } + myPosts = myPosts.filterNot { it.id == id } + refreshList() + } + deletingPostIds = deletingPostIds - id + onResult(result) + } + } - if (result.isSuccessful) { - lostFoundList = - lostFoundList.filterNot { - it.id == id + fun loadMyPosts() { + if (myPostsLoading) return + viewModelScope.launch { + myPostsLoading = true + myPostsError = null + val result = runCatching { + coroutineScope { + val found = async { loadAllPostsForState(1) } + val wanted = async { loadAllPostsForState(2) } + (found.await() + wanted.await()) + .filter { item -> + item.createuser == currentUserName || + item.pubuser?.idNumber == currentUserName } - - refreshList() + .distinctBy(LostFoundItem::id) + .sortedByDescending(LostFoundItem::createtime) } - } catch (_: Exception) { } + } + result.onSuccess { myPosts = it } + .onFailure { myPostsError = it.message ?: "加载我的帖子失败" } + myPostsLoading = false } } + private suspend fun loadAllPostsForState(state: Int): List { + val posts = mutableListOf() + var page = 1 + var pages = 1 + do { + val response = AHURepository.getLostFoundList( + pageNo = page, + pageSize = MY_POST_PAGE_SIZE, + state = state + ) + check(response.isSuccessful) { response.msg ?: "加载帖子失败" } + posts += response.data.data.list + pages = response.data.data.pages.coerceAtLeast(1) + page++ + } while (page <= pages) + return posts + } + fun applyPresetCandidate(candidate: PresetCandidate) = viewModelScope.launch { filterCommitJob?.cancel() val applied = behaviorRuntime.applyLocalPreset(candidate) ?: return@launch @@ -424,11 +495,11 @@ class LostFoundViewModel @Inject constructor( fetchFirstPage(commitPresetOnDispatch = true) } - private fun scheduleFilterQuery() { + private fun scheduleFilterCommit() { filterCommitJob?.cancel() filterCommitJob = viewModelScope.launch { delay(FILTER_SETTLE_MS) - fetchFirstPage(commitPresetOnDispatch = true) + recordCurrentPresetDispatch() } } @@ -508,10 +579,14 @@ class LostFoundViewModel @Inject constructor( } } - private companion object { const val FILTER_SETTLE_MS = 800L } + private companion object { + const val FILTER_SETTLE_MS = 800L + const val MY_POST_PAGE_SIZE = 100 + } override fun onCleared() { filterCommitJob?.cancel() + listRequestJob?.cancel() onPresetSurfaceDisposed() super.onCleared() } diff --git a/app/src/main/java/com/ahu/ahutong/ui/state/MainViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/state/MainViewModel.kt index 54de81bb..58eeab67 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/state/MainViewModel.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/state/MainViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -41,7 +42,6 @@ import java.util.PriorityQueue class MainViewModel : ViewModel() { companion object { - private val apkDownloadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val gson = Gson() private const val DOWNLOAD_BUFFER_SIZE = 64 * 1024 private const val PROGRESS_MIN_INTERVAL_MS = 1_000L @@ -68,6 +68,8 @@ class MainViewModel : ViewModel() { private const val HTTP_PARTIAL_CONTENT = 206 } + private val apkDownloadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private class RangeUnsupportedException(message: String) : IOException(message) private data class ContentRange( @@ -141,7 +143,7 @@ class MainViewModel : ViewModel() { private fun sha256Of(file: File): String { val digest = MessageDigest.getInstance("SHA-256") file.inputStream().use { input -> - val buffer = ByteArray(8 * 1024) + val buffer = ByteArray(DOWNLOAD_BUFFER_SIZE) var read = input.read(buffer) while (read >= 0) { digest.update(buffer, 0, read) @@ -380,11 +382,12 @@ class MainViewModel : ViewModel() { ) } - replaceDownloadedApk(downloadedFile, outFile, update.sha256) + val verifiedSha256 = replaceDownloadedApk(downloadedFile, outFile, update.sha256) metaFile.delete() Log.i( "ApkUpdate", - "apk download verified version=${update.info.versionCode}, bytes=${outFile.length()}" + "apk download verified version=${update.info.versionCode}, " + + "bytes=${outFile.length()}, sha256=$verifiedSha256" ) withContext(Dispatchers.Main) { @@ -441,7 +444,13 @@ class MainViewModel : ViewModel() { val appContext = context.applicationContext apkDownloadScope.launch { - apkDownloadJob?.cancelAndJoin() + val previousDownload = apkDownloadJob + previousDownload?.cancel() + // Cancelling the coroutine alone cannot interrupt a blocking ResponseBody read. Close + // every call on the dedicated APK client before waiting, so switching sources does not + // stall until the network read timeout expires. + AhuTong.cancelApkDownloads() + previousDownload?.join() withContext(Dispatchers.Main) { if (apkLocalReady.value) { apkDownloading.value = false @@ -1552,18 +1561,11 @@ class MainViewModel : ViewModel() { throw IOException("下载文件大小异常(${partFile.length()}/${probe.totalBytes})") } - val hash = runCatching { sha256Of(partFile) }.getOrNull() - if (!hash.equals(update.sha256, ignoreCase = true)) { - Log.w("ApkUpdate", "download sha256 mismatch: expected=${update.sha256}, got=$hash") - deletePartialDownload(partFile, metaFile) - throw SecurityException("文件校验失败,请重试") - } - val elapsed = System.currentTimeMillis() - startedAt Log.i( "ApkUpdate", "adaptive range download complete bytes=${probe.totalBytes}, elapsedMs=$elapsed, " + - "avg=${speedText(probe.totalBytes, elapsed)}, sha256=$hash" + "avg=${speedText(probe.totalBytes, elapsed)}" ) return partFile } @@ -1963,12 +1965,6 @@ class MainViewModel : ViewModel() { "avg=${speedText(completed, elapsed)}" ) - val hash = runCatching { sha256Of(partFile) }.getOrNull() - if (!hash.equals(update.sha256, ignoreCase = true)) { - Log.w("ApkUpdate", "download sha256 mismatch: expected=${update.sha256}, got=$hash") - partFile.delete() - throw SecurityException("文件校验失败,请重试") - } return partFile } @@ -2098,12 +2094,28 @@ class MainViewModel : ViewModel() { return String.format(Locale.US, "%.1f%%", value * 100.0) } - private fun replaceDownloadedApk(partFile: File, outFile: File, expectedSha256: String) { + private fun replaceDownloadedApk( + partFile: File, + outFile: File, + expectedSha256: String + ): String { + val sourceHash = runCatching { sha256Of(partFile) }.getOrNull() + ?: throw SecurityException("文件校验失败,请重试") + if (!sourceHash.equals(expectedSha256, ignoreCase = true)) { + Log.w( + "ApkUpdate", + "download sha256 mismatch: expected=$expectedSha256, got=$sourceHash" + ) + partFile.delete() + throw SecurityException("文件校验失败,请重试") + } + if (outFile.exists() && !outFile.delete()) { throw IOException("无法替换旧安装包") } - if (!partFile.renameTo(outFile)) { + val renamed = partFile.renameTo(outFile) + if (!renamed) { partFile.inputStream().use { input -> FileOutputStream(outFile).use { output -> input.copyTo(output) @@ -2112,11 +2124,13 @@ class MainViewModel : ViewModel() { if (!partFile.delete()) { Log.w("ApkUpdate", "failed to delete temporary APK: ${partFile.name}") } + // A cross-filesystem fallback copy is uncommon, but its destination still needs an + // independent integrity check. The normal atomic rename path reuses the source hash. + if (!verifyCachedApk(outFile, expectedSha256, "copied APK")) { + throw SecurityException("文件校验失败,请重试") + } } - - if (!verifyCachedApk(outFile, expectedSha256, "downloaded APK")) { - throw SecurityException("文件校验失败,请重试") - } + return sourceHash } private suspend fun emitApkProgress(progress: Float) { @@ -2241,4 +2255,10 @@ class MainViewModel : ViewModel() { CookieManager.getInstance().removeAllCookies(null) CookieManager.getInstance().flush() } + + override fun onCleared() { + AhuTong.cancelApkDownloads() + apkDownloadScope.cancel() + super.onCleared() + } } diff --git a/app/src/main/java/com/ahu/ahutong/ui/state/NetworkRechargeViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/state/NetworkRechargeViewModel.kt index f4aa1010..3f800bd7 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/state/NetworkRechargeViewModel.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/state/NetworkRechargeViewModel.kt @@ -299,7 +299,7 @@ class NetworkRechargeViewModel : ViewModel() { private suspend fun fetchFeeItem(): AHUResponse { val responseWrapper = AHUResponse() - val response = YcardApi.API.getSingleFeeItem(NETWORK_FEE_ITEM_ID) + val response = YcardApi.authorizedCall { getSingleFeeItem(NETWORK_FEE_ITEM_ID) } return parseJsonResponse(response, responseWrapper) { body -> val parsed = Gson().fromJson(body, NetworkFeeItemPageResponse::class.java) if (parsed.code == 200 && parsed.feeitem != null) { @@ -320,7 +320,7 @@ class NetworkRechargeViewModel : ViewModel() { .add("type", "IEC") .add("level", "0") .build() - val response = YcardApi.API.getFeeItemThirdData(formBody) + val response = YcardApi.authorizedCall { getFeeItemThirdData(formBody) } return parseJsonResponse(response, responseWrapper) { body -> val parsed = Gson().fromJson(body, NetworkFeeInfoResponse::class.java) val map = parsed.map @@ -355,7 +355,7 @@ class NetworkRechargeViewModel : ViewModel() { "third_party" to Gson().toJson(thirdPartyData) ) ) - val response = YcardApi.API.pay(formBody) + val response = YcardApi.authorizedCall { pay(formBody) } return parseJsonResponse(response, responseWrapper) { body -> val parsed = Gson().fromJson(body, NetworkOrderResponse::class.java) if (parsed.code == 200 && parsed.data != null) { @@ -379,7 +379,7 @@ class NetworkRechargeViewModel : ViewModel() { "orderid" to orderId ) ) - val response = YcardApi.API.pay(formBody) + val response = YcardApi.authorizedCall { pay(formBody) } return parseJsonResponse(response, responseWrapper) { body -> val parsed = Gson().fromJson(body, NetworkAccountPayInfoResponse::class.java) if (parsed.code == 200 && parsed.data?.passwordMap?.isNotEmpty() == true) { @@ -412,7 +412,7 @@ class NetworkRechargeViewModel : ViewModel() { "isWX" to "0" ) ) - val response = YcardApi.API.pay(formBody) + val response = YcardApi.authorizedCall { pay(formBody) } return parseJsonResponse(response, responseWrapper) { body -> val parsed = Gson().fromJson(body, NetworkFinalPayResponse::class.java) if (parsed.code == 200 && parsed.success && !parsed.data.isNullOrBlank()) { @@ -459,7 +459,7 @@ class NetworkRechargeViewModel : ViewModel() { wrapper: AHUResponse, errorMessage: String ): AHUResponse { - val response = YcardApi.API.getFeeItemThirdData(formBody) + val response = YcardApi.authorizedCall { getFeeItemThirdData(formBody) } return parseJsonResponse(response, wrapper) { body -> val responseBody = Gson().fromJson(body, ThirdDataResponse::class.java) if (responseBody.code == 200) { 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 ec23d9e7..c2ef0c4e 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 @@ -2,17 +2,19 @@ package com.ahu.ahutong.ui.state import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ahu.ahutong.data.dao.AHUCache import com.ahu.ahutong.data.dao.PreferencesManager +import com.ahu.ahutong.data.dao.DEFAULT_THEME_COLOR import com.ahu.ahutong.data.model.AppThemeMode +import com.ahu.ahutong.data.model.AppUiTheme import com.ahu.ahutong.personalization.runtime.BehaviorPredictionRuntime import com.ahu.ahutong.personalization.bootstrap.BootstrapContributionStatus import com.ahu.ahutong.personalization.semantic.MutationId import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -21,6 +23,8 @@ class PreferencesViewModel @Inject constructor( private val behaviorRuntime: BehaviorPredictionRuntime ) : ViewModel() { + private val startupThemePreferences = preferencesManager.getStartupThemePreferences() + private val _personalizationEnabled = MutableStateFlow(null) val personalizationEnabled: StateFlow = _personalizationEnabled.asStateFlow() @@ -36,19 +40,28 @@ class PreferencesViewModel @Inject constructor( private val _showQRCode = MutableStateFlow(false) val showQRCode: StateFlow = _showQRCode.asStateFlow() - private val _useCmbCardRecharge = MutableStateFlow(AHUCache.isCmbCardRechargePreferred()) - val useCmbCardRecharge: StateFlow = _useCmbCardRecharge.asStateFlow() - private val _isShowAllCourse = MutableStateFlow(false) val isShowAllCourse: StateFlow = _isShowAllCourse.asStateFlow() - private val _useLiquidGlass = MutableStateFlow(true) - val useLiquidGlass: StateFlow = _useLiquidGlass.asStateFlow() + private val _appUiTheme = MutableStateFlow( + startupThemePreferences?.appUiTheme ?: AppUiTheme.LIQUID_GLASS + ) + val appUiTheme: StateFlow = _appUiTheme.asStateFlow() + + private val _useBuiltInSecurePasswordKeyboard = MutableStateFlow(true) + val useBuiltInSecurePasswordKeyboard: StateFlow = + _useBuiltInSecurePasswordKeyboard.asStateFlow() + + private val _isUiThemePreferenceReady = MutableStateFlow(startupThemePreferences != null) + val isUiThemePreferenceReady: StateFlow = + _isUiThemePreferenceReady.asStateFlow() - private val _themeColor = MutableStateFlow(null) + private val _themeColor = MutableStateFlow(startupThemePreferences?.themeColor) val themeColor: StateFlow = _themeColor.asStateFlow() - private val _appThemeMode = MutableStateFlow(AppThemeMode.FOLLOW_SYSTEM) + private val _appThemeMode = MutableStateFlow( + startupThemePreferences?.themeMode ?: AppThemeMode.FOLLOW_SYSTEM + ) val appThemeMode: StateFlow = _appThemeMode.asStateFlow() private val _courseReminderEnabled = MutableStateFlow(false) @@ -70,12 +83,25 @@ 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 + combine( + preferencesManager.appUiTheme, + preferencesManager.themeColor, + preferencesManager.themeMode + ) { appUiTheme, themeColor, themeMode -> + Triple(appUiTheme, themeColor, themeMode) + }.collect { (appUiTheme, themeColor, themeMode) -> + _appUiTheme.value = appUiTheme + _themeColor.value = themeColor + _appThemeMode.value = themeMode + _isUiThemePreferenceReady.value = true + preferencesManager.rememberStartupThemePreferences( + appUiTheme = appUiTheme, + themeColor = themeColor, + themeMode = themeMode + ) } - } + } viewModelScope.launch { preferencesManager.showQRCode.collect { _showQRCode.value = it @@ -86,11 +112,11 @@ class PreferencesViewModel @Inject constructor( _isShowAllCourse.value = it } } - viewModelScope.launch { - preferencesManager.useLiquidGlass.collect { - _useLiquidGlass.value = it - } - } + viewModelScope.launch { + preferencesManager.useBuiltInSecurePasswordKeyboard.collect { + _useBuiltInSecurePasswordKeyboard.value = it + } + } viewModelScope.launch { preferencesManager.courseReminderEnabled.collect { _courseReminderEnabled.value = it @@ -167,13 +193,29 @@ class PreferencesViewModel @Inject constructor( } } - fun setUseLiquidGlass(value: Boolean) { + fun setAppUiTheme(value: AppUiTheme) { + val oldValue = _appUiTheme.value + _appUiTheme.value = value + val nextThemeColor = when { + value == AppUiTheme.MIUIX -> DEFAULT_THEME_COLOR + _themeColor.value == DEFAULT_THEME_COLOR -> null + else -> _themeColor.value + } + _themeColor.value = nextThemeColor viewModelScope.launch { - val oldValue = _useLiquidGlass.value - preferencesManager.setUseLiquidGlass(value) - behaviorRuntime.recordCommittedMutation(MutationId.LIQUID_GLASS_CHANGED, oldValue, value) - } - } + // The Miuix default is a real preference, not just a temporary UI selection. + // Persist it with the theme switch so the color collector cannot restore the + // previous system accent during a hot switch or after process recreation. + preferencesManager.setThemeColor(nextThemeColor) + preferencesManager.setAppUiTheme(value) + behaviorRuntime.recordCommittedMutation( + MutationId.THEME_CHANGED, + oldValue.storageValue, + value.storageValue, + coarseValueBucket = "UI_STYLE_CHANGED" + ) + } + } fun setCourseReminderEnabled(value: Boolean) { viewModelScope.launch { @@ -183,24 +225,9 @@ class PreferencesViewModel @Inject constructor( } } - fun setUseCmbCardRecharge(value: Boolean) { + fun setUseBuiltInSecurePasswordKeyboard(value: Boolean) { viewModelScope.launch { - val oldValue = AHUCache.isCmbCardRechargePreferred() - if (oldValue == value) { - _useCmbCardRecharge.value = oldValue - return@launch - } - AHUCache.setCmbCardRechargePreferred(value) - val committedValue = AHUCache.isCmbCardRechargePreferred() - _useCmbCardRecharge.value = committedValue - if (committedValue == value) { - behaviorRuntime.recordCommittedMutation( - MutationId.CMB_RECHARGE_PREFERENCE_CHANGED, - oldValue, - committedValue, - coarseValueBucket = if (committedValue) "ENABLED" else "DISABLED" - ) - } + preferencesManager.setUseBuiltInSecurePasswordKeyboard(value) } } @@ -213,8 +240,9 @@ class PreferencesViewModel @Inject constructor( } fun setThemeColor(value: String?) { + val oldValue = _themeColor.value + _themeColor.value = value viewModelScope.launch { - val oldValue = _themeColor.value preferencesManager.setThemeColor(value) behaviorRuntime.recordCommittedMutation(MutationId.THEME_CHANGED, oldValue, value, coarseValueBucket = "COLOR_CHANGED") } diff --git a/app/src/main/java/com/ahu/ahutong/ui/state/RepositoryViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/state/RepositoryViewModel.kt index 2b8b2769..f543407d 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/state/RepositoryViewModel.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/state/RepositoryViewModel.kt @@ -15,10 +15,12 @@ import com.ahu.ahutong.data.repository.GitHubContentItem import com.ahu.ahutong.data.repository.RepositoryDirectorySummary import com.ahu.ahutong.data.repository.RepositoryMarkdownDocument import com.ahu.ahutong.data.repository.RepositoryManager +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File data class RepositoryUiState( @@ -62,23 +64,28 @@ class RepositoryViewModel(application: Application) : AndroidViewModel(applicati private val _directoryStates = MutableStateFlow>(emptyMap()) val directoryStates: StateFlow> = _directoryStates.asStateFlow() - private val _sharedState = MutableStateFlow( - RepositorySharedUiState(downloadedPaths = refreshDownloadedSet()) - ) + private val _sharedState = MutableStateFlow(RepositorySharedUiState()) val sharedState: StateFlow = _sharedState.asStateFlow() private val _markdownState = MutableStateFlow(RepositoryMarkdownUiState()) val markdownState: StateFlow = _markdownState.asStateFlow() + init { + viewModelScope.launch { + val downloadedPaths = withContext(Dispatchers.IO) { refreshDownloadedSet() } + _sharedState.value = _sharedState.value.copy(downloadedPaths = downloadedPaths) + } + } + fun getInitialDirectoryState(path: String): RepositoryUiState { - return _directoryStates.value[path] ?: cachedDirectoryState(path) ?: RepositoryUiState( + return _directoryStates.value[path] ?: RepositoryUiState( currentPath = path, isLoading = true ) } fun getDirectoryState(path: String): RepositoryUiState { - return _directoryStates.value[path] ?: cachedDirectoryState(path) ?: RepositoryUiState( + return _directoryStates.value[path] ?: RepositoryUiState( currentPath = path, isLoading = true ) @@ -96,13 +103,7 @@ class RepositoryViewModel(application: Application) : AndroidViewModel(applicati fun loadContents(path: String = "", forceRefresh: Boolean = false) { val requestId = ++loadRequestId pathRequestIds[path] = requestId - val cached = if (forceRefresh) null else RepositoryManager.getCachedContents(path) - val startState = _directoryStates.value[path] ?: cachedDirectoryState(path) - - if (cached != null) { - setDirectoryState(path, directoryStateFromCache(path, cached.items, cached.updateTime)) - return - } + val startState = _directoryStates.value[path] setDirectoryState( path, @@ -116,35 +117,38 @@ class RepositoryViewModel(application: Application) : AndroidViewModel(applicati viewModelScope.launch { try { - val items = RepositoryManager.getContents(path, forceRefresh = forceRefresh) + val resolvedState = withContext(Dispatchers.IO) { + val cached = if (forceRefresh) null else RepositoryManager.getCachedContents(path) + if (cached != null) { + directoryStateFromCache(path, cached.items, cached.updateTime) + } else { + val items = RepositoryManager.getContents(path, forceRefresh = forceRefresh) + val sortedItems = sortDisplayItems(path, items) + RepositoryUiState( + isLoading = false, + isRefreshing = false, + isLoaded = true, + items = sortedItems, + currentPath = path, + isShowingCachedContents = false, + cacheUpdatedAt = System.currentTimeMillis(), + directorySummaries = RepositoryManager.getDirectorySummaries(sortedItems) + ) + } + } if (pathRequestIds[path] != requestId) return@launch - val sortedItems = sortDisplayItems(path, items) - setDirectoryState( - path, - RepositoryUiState( - isLoading = false, - isRefreshing = false, - isLoaded = true, - items = sortedItems, - currentPath = path, - isShowingCachedContents = false, - cacheUpdatedAt = System.currentTimeMillis(), - directorySummaries = RepositoryManager.getDirectorySummaries(sortedItems) - ) - ) - _sharedState.value = _sharedState.value.copy( - downloadedPaths = refreshDownloadedSet() - ) + setDirectoryState(path, resolvedState) } catch (e: Exception) { if (pathRequestIds[path] != requestId) return@launch - val fallback = RepositoryManager.getCachedContents(path) - if (fallback != null) { - setDirectoryState( - path, + val fallbackState = withContext(Dispatchers.IO) { + RepositoryManager.getCachedContents(path)?.let { fallback -> directoryStateFromCache(path, fallback.items, fallback.updateTime).copy( error = null ) - ) + } + } + if (fallbackState != null) { + setDirectoryState(path, fallbackState) } else { setDirectoryState( path, @@ -167,8 +171,8 @@ class RepositoryViewModel(application: Application) : AndroidViewModel(applicati cacheWarmUpCount = 0 ) viewModelScope.launch { - runCatching { - RepositoryManager.warmUpAllContentCaches( + try { + val updateTime = RepositoryManager.warmUpAllContentCaches( forceRefresh = forceRefresh, onProgress = { fetchedCount -> _sharedState.value = _sharedState.value.copy( @@ -177,11 +181,17 @@ class RepositoryViewModel(application: Application) : AndroidViewModel(applicati ) } ) - }.onSuccess { updateTime -> - val states = _directoryStates.value.toMutableMap() - states.keys.toList().forEach { path -> - RepositoryManager.getCachedContents(path)?.let { cached -> - states[path] = directoryStateFromCache(path, cached.items, updateTime) + val states = withContext(Dispatchers.IO) { + _directoryStates.value.toMutableMap().also { currentStates -> + currentStates.keys.toList().forEach { path -> + RepositoryManager.getCachedContents(path)?.let { cached -> + currentStates[path] = directoryStateFromCache( + path, + cached.items, + updateTime + ) + } + } } } _directoryStates.value = states @@ -189,7 +199,7 @@ class RepositoryViewModel(application: Application) : AndroidViewModel(applicati isCacheWarming = false, cacheWarmUpCount = 0 ) - }.onFailure { + } catch (_: Exception) { _sharedState.value = _sharedState.value.copy( isCacheWarming = false, cacheWarmUpCount = 0 @@ -224,7 +234,7 @@ class RepositoryViewModel(application: Application) : AndroidViewModel(applicati ) } if (file != null) { - val downloads = refreshDownloadedSet() + val downloads = withContext(Dispatchers.IO) { refreshDownloadedSet() } _sharedState.value = _sharedState.value.copy( downloadingPath = null, downloadedPaths = downloads, @@ -262,9 +272,13 @@ class RepositoryViewModel(application: Application) : AndroidViewModel(applicati } fun deleteFile(path: String) { - RepositoryManager.deleteFile(path, context) - val downloads = refreshDownloadedSet() - _sharedState.value = _sharedState.value.copy(downloadedPaths = downloads) + viewModelScope.launch { + val downloads = withContext(Dispatchers.IO) { + RepositoryManager.deleteFile(path, context) + refreshDownloadedSet() + } + _sharedState.value = _sharedState.value.copy(downloadedPaths = downloads) + } } fun getRawUrl(path: String): String = RepositoryManager.getRawUrl(path) @@ -420,11 +434,6 @@ class RepositoryViewModel(application: Application) : AndroidViewModel(applicati } } - private fun cachedDirectoryState(path: String): RepositoryUiState? { - val cached = RepositoryManager.getCachedContents(path) ?: return null - return directoryStateFromCache(path, cached.items, cached.updateTime) - } - private fun directoryStateFromCache( path: String, items: List, @@ -450,7 +459,7 @@ class RepositoryViewModel(application: Application) : AndroidViewModel(applicati private fun setPathError(path: String, message: String) { setDirectoryState( path, - (_directoryStates.value[path] ?: cachedDirectoryState(path) ?: RepositoryUiState( + (_directoryStates.value[path] ?: RepositoryUiState( currentPath = path )).copy(error = message) ) diff --git a/app/src/main/java/com/ahu/ahutong/ui/state/ScheduleViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/state/ScheduleViewModel.kt index b04d149f..52696a0f 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/state/ScheduleViewModel.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/state/ScheduleViewModel.kt @@ -52,32 +52,27 @@ class ScheduleViewModel () : ViewModel() { */ fun refreshSchedule(isRefresh:Boolean = false) { viewModelScope.launchSafe { - withContext(Dispatchers.Main){ - if (!AHUCache.isLogin() && !AHUCache.getMockData()) { - schedule.value = Result.failure(Throwable("请先登录!")) - return@withContext - } - - val result = AHURepository.getSchedule(isRefresh = isRefresh) - schedule.value = result - if (result.isSuccess) { - CourseReminderScheduler.reschedule(AHUApplication.getApp()) - } + if (!AHUCache.isLogin() && !AHUCache.getMockData()) { + schedule.value = Result.failure(Throwable("请先登录!")) + return@launchSafe } + val result = AHURepository.getSchedule(isRefresh = isRefresh) + schedule.value = result + if (result.isSuccess) { + CourseReminderScheduler.reschedule(AHUApplication.getApp()) + } } } fun refreshNextSchedule(isRefresh: Boolean = false) { viewModelScope.launchSafe { - withContext(Dispatchers.Main) { - if (!AHUCache.isLogin() && !AHUCache.getMockData()) { - nextSchedule.value = Result.failure(Throwable("请先登录")) - return@withContext - } - - nextSchedule.value = AHURepository.getNextSchedule(isRefresh = isRefresh) + if (!AHUCache.isLogin() && !AHUCache.getMockData()) { + nextSchedule.value = Result.failure(Throwable("请先登录")) + return@launchSafe } + + nextSchedule.value = AHURepository.getNextSchedule(isRefresh = isRefresh) } } @@ -158,33 +153,25 @@ class ScheduleViewModel () : ViewModel() { ) } - /** - * @param from "HH:mm-HH:mm" - * @param to "HH:mm-HH:mm" - */ - private fun getTimeRangeInMinutes( - from: String, - to: String = from - ): IntRange { - val format = SimpleDateFormat("HH:mm", Locale.CHINA) - val start = format.parse(from.take(5)).let { - val calendar = Calendar.getInstance(Locale.CHINA) - calendar.time = it!! - calendar.get(Calendar.HOUR_OF_DAY) * 60 + calendar.get(Calendar.MINUTE) - } - val end = format.parse(to.takeLast(5)).let { - val calendar = Calendar.getInstance(Locale.CHINA) - calendar.time = it!! - calendar.get(Calendar.HOUR_OF_DAY) * 60 + calendar.get(Calendar.MINUTE) + /** Pre-parsed once because the home timeline reads these ranges during composition. */ + private val timetableMinuteRanges by lazy { + timetable.mapValues { (_, range) -> + parseClockMinutes(range.substringBefore('-')).. + parseClockMinutes(range.substringAfter('-')) } - return start..end + } + + private fun parseClockMinutes(clock: String): Int { + val separator = clock.indexOf(':') + require(separator > 0 && separator < clock.lastIndex) { "Invalid clock: $clock" } + return clock.substring(0, separator).toInt() * 60 + + clock.substring(separator + 1).toInt() } fun getCourseTimeRangeInMinutes(course: Course): IntRange { - return getTimeRangeInMinutes( - from = timetable.getValue(course.startTime), - to = timetable.getValue(course.startTime + course.length - 1) - ) + val firstSection = timetableMinuteRanges.getValue(course.startTime) + val lastSection = timetableMinuteRanges.getValue(course.startTime + course.length - 1) + return firstSection.first..lastSection.last } } diff --git a/app/src/main/java/com/ahu/ahutong/ui/state/WeatherViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/state/WeatherViewModel.kt index afed490e..ee24b86d 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/state/WeatherViewModel.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/state/WeatherViewModel.kt @@ -1,6 +1,8 @@ package com.ahu.ahutong.ui.state +import android.Manifest import android.content.Context +import android.content.pm.PackageManager import android.location.Geocoder import android.location.LocationManager import android.util.Log @@ -9,6 +11,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.core.content.ContextCompat import com.ahu.ahutong.data.dao.AHUCache import com.ahu.ahutong.data.weather.WeatherApi import com.ahu.ahutong.data.weather.WeatherResponse @@ -51,18 +54,38 @@ data class WeatherHomeConfig( AHUCache.saveWeatherHomeShowWeather(showWeather) AHUCache.saveWeatherHomeShowAqi(showAqi) AHUCache.saveWeatherHomeShowLocation(showLocation) + cachedConfig = CachedWeatherHomeConfig(AHUCache.getCurrentUser()?.xh, this) } companion object { + private data class CachedWeatherHomeConfig( + val userId: String?, + val config: WeatherHomeConfig + ) + + @Volatile + private var cachedConfig: CachedWeatherHomeConfig? = null + fun fromCache(): WeatherHomeConfig { - return WeatherHomeConfig( - showOnHome = AHUCache.getWeatherShowOnHome(), - mode = WeatherHomeMode.fromCacheValue(AHUCache.getWeatherHomeMode()), - showTemp = AHUCache.getWeatherHomeShowTemp(), - showWeather = AHUCache.getWeatherHomeShowWeather(), - showAqi = AHUCache.getWeatherHomeShowAqi(), - showLocation = AHUCache.getWeatherHomeShowLocation(), - ) + val userId = AHUCache.getCurrentUser()?.xh + cachedConfig + ?.takeIf { it.userId == userId } + ?.let { return it.config } + return synchronized(this) { + cachedConfig + ?.takeIf { it.userId == userId } + ?.config + ?: WeatherHomeConfig( + showOnHome = AHUCache.getWeatherShowOnHome(), + mode = WeatherHomeMode.fromCacheValue(AHUCache.getWeatherHomeMode()), + showTemp = AHUCache.getWeatherHomeShowTemp(), + showWeather = AHUCache.getWeatherHomeShowWeather(), + showAqi = AHUCache.getWeatherHomeShowAqi(), + showLocation = AHUCache.getWeatherHomeShowLocation(), + ).also { config -> + cachedConfig = CachedWeatherHomeConfig(userId, config) + } + } } } } @@ -111,7 +134,7 @@ class WeatherViewModel @Inject constructor( Log.d("Weather", "Weather content loaded") reportReady() } catch (e: Exception) { - Log.e("Weather", "Failed to fetch weather") + Log.e("Weather", "Failed to fetch weather", e) errorMessage = e.message ?: "获取天气失败" reportError() } finally { @@ -143,7 +166,7 @@ class WeatherViewModel @Inject constructor( Log.d("Weather", "Weather content loaded by saved location") reportReady() } catch (e: Exception) { - Log.e("Weather", "Failed to fetch weather by saved location") + Log.e("Weather", "Failed to fetch weather by saved location", e) errorMessage = e.message ?: "获取天气失败" reportError() } finally { @@ -195,13 +218,14 @@ class WeatherViewModel @Inject constructor( reportReady() } } catch (e: Exception) { - Log.e("Weather", "Failed to fetch weather by location") + Log.e("Weather", "Failed to fetch weather by location", e) try { val result = WeatherApi.API.getWeather() weather = result errorMessage = null reportReady() } catch (e2: Exception) { + Log.e("Weather", "IP weather fallback failed", e2) errorMessage = e2.message ?: "获取天气失败" reportError() } @@ -216,6 +240,16 @@ class WeatherViewModel @Inject constructor( * 尝试获取区级名称(locality = 蜀山区),否则市(subAdminArea = 合肥市) */ private fun getCityNameFromGps(context: Context): String? { + val hasFineLocation = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + val hasCoarseLocation = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + if (!hasFineLocation && !hasCoarseLocation) return null + val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager val location = runCatching { locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) 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 c845bf12..7dd43cfd 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 @@ -6,8 +6,12 @@ import android.content.ContextWrapper import android.content.res.Configuration import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect @@ -16,11 +20,15 @@ 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.LocalContext 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.components.LocalAppUiTheme +import com.ahu.ahutong.data.model.AppUiTheme +import com.ahu.ahutong.data.dao.DEFAULT_THEME_COLOR import com.ahu.ahutong.ui.state.PreferencesViewModel import com.kyant.monet.LocalTonalPalettes import com.kyant.monet.TonalPalettes.Companion.toTonalPalettes @@ -28,14 +36,21 @@ import com.kyant.monet.dynamicColorScheme import com.kyant.monet.n1 import com.kyant.monet.toColor import com.kyant.monet.toSrgb +import top.yukonga.miuix.kmp.basic.Scaffold as MiuixScaffold +import top.yukonga.miuix.kmp.theme.ColorSchemeMode +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.theme.ThemeController @Composable fun AHUTheme(content: @Composable () -> Unit) { val preferencesViewModel: PreferencesViewModel = hiltViewModel() val themeColorHex by preferencesViewModel.themeColor.collectAsState() val themeMode by preferencesViewModel.appThemeMode.collectAsState() - val useLiquidGlass by preferencesViewModel.useLiquidGlass.collectAsState() + val appUiTheme by preferencesViewModel.appUiTheme.collectAsState() + val isUiThemePreferenceReady by + preferencesViewModel.isUiThemePreferenceReady.collectAsState() val isDarkTheme = themeMode.resolve(isSystemInDarkTheme()) + val context = LocalContext.current val configuration = LocalConfiguration.current val themeConfiguration = remember(configuration, isDarkTheme) { Configuration(configuration).apply { @@ -58,12 +73,17 @@ fun AHUTheme(content: @Composable () -> Unit) { } } - val customKeyColor = remember(themeColorHex) { - themeColorHex?.let { value -> + val usesBuiltInDefaultColor = + appUiTheme == AppUiTheme.MIUIX && themeColorHex == DEFAULT_THEME_COLOR + val customKeyColor = remember(themeColorHex, usesBuiltInDefaultColor) { + themeColorHex + ?.takeUnless { it == DEFAULT_THEME_COLOR } + ?.let { value -> runCatching { Color(android.graphics.Color.parseColor(value)) }.getOrNull() } } val keyColor = when { + usesBuiltInDefaultColor -> Color(0xFF3482FF) customKeyColor != null -> customKeyColor Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> colorResource(id = android.R.color.system_accent1_500) @@ -77,12 +97,99 @@ fun AHUTheme(content: @Composable () -> Unit) { 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 + val colorScheme = if ( + customKeyColor == null && + !usesBuiltInDefaultColor && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + ) { + if (isDarkTheme) { + dynamicDarkColorScheme(context) + } else { + dynamicLightColorScheme(context) + } + } else { + val generated = dynamicColorScheme(isLight = !isDarkTheme) + if (isDarkTheme) { + generated.copy( + background = 6.n1, + onBackground = 90.n1, + surface = 6.n1, + onSurface = 90.n1, + surfaceVariant = 30.n1, + onSurfaceVariant = 80.n1, + inverseSurface = 90.n1, + inverseOnSurface = 20.n1, + outline = 60.n1, + outlineVariant = 30.n1, + surfaceBright = 24.n1, + surfaceDim = 6.n1, + surfaceContainerLowest = 4.n1, + surfaceContainerLow = 10.n1, + surfaceContainer = 12.n1, + surfaceContainerHigh = 17.n1, + surfaceContainerHighest = 22.n1 + ) + } else { + generated.copy( + background = 98.n1, + onBackground = 10.n1, + surface = 98.n1, + onSurface = 10.n1, + surfaceVariant = 90.n1, + onSurfaceVariant = 30.n1, + inverseSurface = 20.n1, + inverseOnSurface = 95.n1, + outline = 50.n1, + outlineVariant = 80.n1, + surfaceBright = 98.n1, + surfaceDim = 87.n1, + surfaceContainerLowest = 100.n1, + surfaceContainerLow = 96.n1, + surfaceContainer = 94.n1, + surfaceContainerHigh = 92.n1, + surfaceContainerHighest = 90.n1 + ) + } + } + val miuixUsesSystemColor = themeColorHex == null || + (themeColorHex == DEFAULT_THEME_COLOR && appUiTheme != AppUiTheme.MIUIX) + val miuixColorSchemeMode = when { + // Miuix's own fixed palettes are the HyperOS defaults: #3482FF in light mode and + // #277AF7 in dark mode. Generating a Monet palette from that blue changes the control + // colors and makes "默认" look like a system-derived theme instead. + usesBuiltInDefaultColor && isDarkTheme -> ColorSchemeMode.Dark + usesBuiltInDefaultColor -> ColorSchemeMode.Light + miuixUsesSystemColor -> ColorSchemeMode.MonetSystem + isDarkTheme -> ColorSchemeMode.MonetDark + else -> ColorSchemeMode.MonetLight + } + val miuixController = remember(keyColor, isDarkTheme, miuixColorSchemeMode) { + ThemeController( + colorSchemeMode = miuixColorSchemeMode, + keyColor = keyColor.takeUnless { miuixUsesSystemColor }, + isDark = isDarkTheme + ) + } + MaterialTheme(colorScheme = colorScheme) { + val liquidGlassTokens = rememberLiquidGlassTokens( + enabled = isUiThemePreferenceReady && appUiTheme == AppUiTheme.LIQUID_GLASS ) + MiuixTheme(controller = miuixController) { + CompositionLocalProvider( + LocalContentColor provides if (isDarkTheme) 100.n1 else 0.n1, + LocalAppUiTheme provides appUiTheme, + LocalIsLiquidGlassEnabled provides liquidGlassTokens.enabled, + LocalLiquidGlassTokens provides liquidGlassTokens + ) { + // Keep the root node stable so switching UI libraries never recreates the + // navigation subtree. The transparent scaffold is also Miuix's popup host. + MiuixScaffold( + modifier = androidx.compose.ui.Modifier.fillMaxSize(), + containerColor = Color.Transparent, + contentWindowInsets = WindowInsets(0, 0, 0, 0) + ) { content() } + } + } } } } diff --git a/app/src/main/java/com/ahu/ahutong/ui/theme/LiquidGlassTokens.kt b/app/src/main/java/com/ahu/ahutong/ui/theme/LiquidGlassTokens.kt new file mode 100644 index 00000000..4b1b0566 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/theme/LiquidGlassTokens.kt @@ -0,0 +1,166 @@ +package com.ahu.ahutong.ui.theme + +import android.os.Build +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * The rendering tier used for liquid glass on the current device. + * + * Keeping this policy independent from [Build] makes the API deterministic and unit-testable. + */ +enum class LiquidGlassQuality( + val supportsBackdrop: Boolean, + val supportsBlur: Boolean, + val supportsRefraction: Boolean +) { + Disabled(supportsBackdrop = false, supportsBlur = false, supportsRefraction = false), + Tinted(supportsBackdrop = false, supportsBlur = false, supportsRefraction = false), + Blurred(supportsBackdrop = true, supportsBlur = true, supportsRefraction = false), + Refractive(supportsBackdrop = true, supportsBlur = true, supportsRefraction = true) +} + +fun resolveLiquidGlassQuality(enabled: Boolean, sdkInt: Int): LiquidGlassQuality = when { + !enabled -> LiquidGlassQuality.Disabled + sdkInt >= Build.VERSION_CODES.TIRAMISU -> LiquidGlassQuality.Refractive + sdkInt >= Build.VERSION_CODES.S -> LiquidGlassQuality.Blurred + else -> LiquidGlassQuality.Tinted +} + +/** Visual hierarchy for reusable glass surfaces. */ +enum class LiquidGlassSurfaceLevel { + /** Large, mostly static content groups. Never refracts. */ + Panel, + + /** Navigation, sheets, dialogs, and other surfaces floating over page content. */ + Floating, + + /** Compact interactive controls. Refraction is intentionally restrained. */ + Control +} + +@Immutable +data class LiquidGlassSurfaceTokens( + val tint: Color, + val legacyTint: Color, + val outline: Color, + val blurRadius: Dp, + val refractionHeight: Dp, + val refractionAmount: Dp, + val shadowRadius: Dp, + val shadowColor: Color, + val highlightAlpha: Float +) + +@Immutable +data class LiquidGlassTokens( + val quality: LiquidGlassQuality, + val screenBackground: Color, + val ambientPrimary: Color, + val ambientSecondary: Color, + val panel: LiquidGlassSurfaceTokens, + val floating: LiquidGlassSurfaceTokens, + val control: LiquidGlassSurfaceTokens +) { + val enabled: Boolean + get() = quality != LiquidGlassQuality.Disabled + + fun surface(level: LiquidGlassSurfaceLevel): LiquidGlassSurfaceTokens = when (level) { + LiquidGlassSurfaceLevel.Panel -> panel + LiquidGlassSurfaceLevel.Floating -> floating + LiquidGlassSurfaceLevel.Control -> control + } + + companion object { + val Disabled = LiquidGlassTokens( + quality = LiquidGlassQuality.Disabled, + screenBackground = Color.Transparent, + ambientPrimary = Color.Transparent, + ambientSecondary = Color.Transparent, + panel = disabledSurfaceTokens(), + floating = disabledSurfaceTokens(), + control = disabledSurfaceTokens() + ) + } +} + +val LocalLiquidGlassTokens = staticCompositionLocalOf { LiquidGlassTokens.Disabled } + +@Composable +fun rememberLiquidGlassTokens( + enabled: Boolean, + sdkInt: Int = Build.VERSION.SDK_INT +): LiquidGlassTokens { + val colors = MaterialTheme.colorScheme + val isDark = colors.background.luminance() < 0.5f + return remember(enabled, sdkInt, colors, isDark) { + val outline = if (isDark) { + Color.White.copy(alpha = 0.22f) + } else { + colors.outline.copy(alpha = 0.42f) + } + val shadow = Color.Black.copy(alpha = if (isDark) 0.16f else 0.06f) + val panelBase = if (isDark) colors.surfaceContainer else colors.surface + val floatingBase = if (isDark) colors.surfaceContainerHigh else colors.surfaceContainerLowest + val controlBase = if (isDark) colors.surfaceContainerHighest else colors.surface + + LiquidGlassTokens( + quality = resolveLiquidGlassQuality(enabled, sdkInt), + screenBackground = colors.surfaceContainerLowest, + ambientPrimary = colors.primary.copy(alpha = if (isDark) 0.09f else 0.045f), + ambientSecondary = colors.secondary.copy(alpha = if (isDark) 0.07f else 0.03f), + panel = LiquidGlassSurfaceTokens( + tint = panelBase.copy(alpha = if (isDark) 0.54f else 0.38f), + legacyTint = panelBase.copy(alpha = if (isDark) 0.82f else 0.76f), + outline = outline, + blurRadius = 18.dp, + refractionHeight = 0.dp, + refractionAmount = 0.dp, + shadowRadius = 8.dp, + shadowColor = shadow, + highlightAlpha = if (isDark) 0.22f else 0.28f + ), + floating = LiquidGlassSurfaceTokens( + tint = floatingBase.copy(alpha = if (isDark) 0.56f else 0.44f), + legacyTint = floatingBase.copy(alpha = if (isDark) 0.86f else 0.82f), + outline = outline, + blurRadius = 14.dp, + refractionHeight = 6.dp, + refractionAmount = 12.dp, + shadowRadius = 18.dp, + shadowColor = shadow, + highlightAlpha = if (isDark) 0.26f else 0.34f + ), + control = LiquidGlassSurfaceTokens( + tint = controlBase.copy(alpha = if (isDark) 0.58f else 0.46f), + legacyTint = controlBase.copy(alpha = if (isDark) 0.86f else 0.80f), + outline = outline, + blurRadius = 10.dp, + refractionHeight = 8.dp, + refractionAmount = 14.dp, + shadowRadius = 8.dp, + shadowColor = shadow, + highlightAlpha = if (isDark) 0.28f else 0.38f + ) + ) + } +} + +private fun disabledSurfaceTokens() = LiquidGlassSurfaceTokens( + tint = Color.Transparent, + legacyTint = Color.Transparent, + outline = Color.Transparent, + blurRadius = 0.dp, + refractionHeight = 0.dp, + refractionAmount = 0.dp, + shadowRadius = 0.dp, + shadowColor = Color.Transparent, + highlightAlpha = 0f +) diff --git a/app/src/main/java/com/ahu/ahutong/utils/Navigation.kt b/app/src/main/java/com/ahu/ahutong/utils/Navigation.kt index d2f98459..fe0a28ec 100644 --- a/app/src/main/java/com/ahu/ahutong/utils/Navigation.kt +++ b/app/src/main/java/com/ahu/ahutong/utils/Navigation.kt @@ -1,45 +1,214 @@ package com.ahu.ahutong.utils import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.navigation.NamedNavArgument import androidx.navigation.NavBackStackEntry import androidx.navigation.NavDeepLink import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable +import com.ahu.ahutong.data.model.AppUiTheme +private val primaryDestinationOrder = listOf("home", "schedule", "tools", "settings") + +private fun isPrimaryDestinationTransition(fromRoute: String?, toRoute: String?): Boolean = + fromRoute in primaryDestinationOrder && toRoute in primaryDestinationOrder + +private fun horizontalDirection(fromRoute: String?, toRoute: String?): Int { + val fromIndex = primaryDestinationOrder.indexOf(fromRoute) + val toIndex = primaryDestinationOrder.indexOf(toRoute) + return if (fromIndex >= 0 && toIndex >= 0 && fromIndex != toIndex) { + if (toIndex > fromIndex) 1 else -1 + } else { + 1 + } +} + +@OptIn(ExperimentalAnimationApi::class) +fun NavGraphBuilder.animatedComposable( + route: String, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit +) = animatedComposable( + uiTheme = AppUiTheme.MATERIAL, + route = route, + arguments = arguments, + deepLinks = deepLinks, + content = content +) @OptIn(ExperimentalAnimationApi::class) fun NavGraphBuilder.animatedComposable( + uiTheme: AppUiTheme, route: String, arguments: List = emptyList(), deepLinks: List = emptyList(), content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit +) = animatedComposableWithThemeProvider( + uiTheme = { uiTheme }, + route = route, + arguments = arguments, + deepLinks = deepLinks, + content = content +) + +@OptIn(ExperimentalAnimationApi::class) +fun NavGraphBuilder.animatedComposable( + uiTheme: State, + route: String, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit +) = animatedComposableWithThemeProvider( + uiTheme = { uiTheme.value }, + route = route, + arguments = arguments, + deepLinks = deepLinks, + content = content +) + +@OptIn(ExperimentalAnimationApi::class) +private fun NavGraphBuilder.animatedComposableWithThemeProvider( + uiTheme: () -> AppUiTheme, + route: String, + arguments: List, + deepLinks: List, + content: @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit ) = composable( route = route, arguments = arguments, deepLinks = deepLinks, enterTransition = { - fadeIn(animationSpec = tween(220, delayMillis = 90)) + - scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) + if (initialState.destination.route == "splash") { + EnterTransition.None + } else { + val direction = horizontalDirection( + initialState.destination.route, + targetState.destination.route + ) + if (isPrimaryDestinationTransition( + initialState.destination.route, + targetState.destination.route + ) + ) { + slideInHorizontally( + initialOffsetX = { direction * it }, + animationSpec = tween(220) + ) + } else { + when (uiTheme()) { + AppUiTheme.MATERIAL -> + fadeIn(animationSpec = tween(160)) + + slideInHorizontally( + initialOffsetX = { direction * it / 4 }, + animationSpec = tween(240) + ) + AppUiTheme.MIUIX -> + fadeIn(animationSpec = tween(180)) + + slideInHorizontally( + initialOffsetX = { direction * it / 5 }, + animationSpec = tween(280) + ) + AppUiTheme.LIQUID_GLASS -> + fadeIn(animationSpec = tween(160)) + + slideInHorizontally( + initialOffsetX = { direction * it / 4 }, + animationSpec = tween(260) + ) + } + } + } }, exitTransition = { - fadeOut(animationSpec = tween(90, delayMillis = 90)) + - scaleOut(targetScale = 0.92f, animationSpec = tween(90, delayMillis = 90)) + if (targetState.destination.route == "home" && + initialState.destination.route == "splash" + ) { + ExitTransition.None + } else { + val direction = horizontalDirection( + initialState.destination.route, + targetState.destination.route + ) + if (isPrimaryDestinationTransition( + initialState.destination.route, + targetState.destination.route + ) + ) { + slideOutHorizontally( + targetOffsetX = { -direction * it }, + animationSpec = tween(220) + ) + } else { + when (uiTheme()) { + AppUiTheme.MATERIAL, AppUiTheme.MIUIX, AppUiTheme.LIQUID_GLASS -> + fadeOut(animationSpec = tween(140)) + + slideOutHorizontally( + targetOffsetX = { -direction * it / 12 }, + animationSpec = tween(220) + ) + } + } + } }, popEnterTransition = { - fadeIn(animationSpec = tween(220)) + - scaleIn(initialScale = 0.92f, animationSpec = tween(220)) + val direction = horizontalDirection( + targetState.destination.route, + initialState.destination.route + ) + if (isPrimaryDestinationTransition( + targetState.destination.route, + initialState.destination.route + ) + ) { + slideInHorizontally( + initialOffsetX = { -direction * it }, + animationSpec = tween(220) + ) + } else { + when (uiTheme()) { + AppUiTheme.MATERIAL, AppUiTheme.MIUIX, AppUiTheme.LIQUID_GLASS -> + fadeIn(animationSpec = tween(180)) + + slideInHorizontally( + initialOffsetX = { -direction * it / 12 }, + animationSpec = tween(260) + ) + } + } }, popExitTransition = { - fadeOut(animationSpec = tween(220)) + - scaleOut(targetScale = 0.92f, animationSpec = tween(220)) + val direction = horizontalDirection( + targetState.destination.route, + initialState.destination.route + ) + if (isPrimaryDestinationTransition( + targetState.destination.route, + initialState.destination.route + ) + ) { + slideOutHorizontally( + targetOffsetX = { direction * it }, + animationSpec = tween(220) + ) + } else { + when (uiTheme()) { + AppUiTheme.MATERIAL, AppUiTheme.MIUIX, AppUiTheme.LIQUID_GLASS -> + fadeOut(animationSpec = tween(160)) + + slideOutHorizontally( + targetOffsetX = { direction * it / 4 }, + animationSpec = tween(260) + ) + } + } }, content = content ) diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 9051016f..52d65fcc 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -3,6 +3,5 @@ - diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 2439f15c..430f7541 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,4 +1,9 @@ - + + + + 127.0.0.1 + localhost + diff --git a/app/src/release/java/com/ahu/ahutong/ui/screen/settings/Debug.kt b/app/src/release/java/com/ahu/ahutong/ui/screen/settings/Debug.kt new file mode 100644 index 00000000..30e7c66c --- /dev/null +++ b/app/src/release/java/com/ahu/ahutong/ui/screen/settings/Debug.kt @@ -0,0 +1,13 @@ +package com.ahu.ahutong.ui.screen.settings + +import androidx.compose.runtime.Composable +import com.ahu.ahutong.ui.state.DiscoveryViewModel +import com.ahu.ahutong.ui.state.ScheduleViewModel + +/** Release builds intentionally contain no debug controls. */ +@Composable +fun Debug( + scheduleViewModel: ScheduleViewModel, + discoveryViewModel: DiscoveryViewModel, + onGrayStateChanged: () -> Unit +) = Unit diff --git a/app/src/test/java/com/ahu/ahutong/data/CasLoginActionResolverTest.kt b/app/src/test/java/com/ahu/ahutong/data/CasLoginActionResolverTest.kt new file mode 100644 index 00000000..7fca0eee --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/data/CasLoginActionResolverTest.kt @@ -0,0 +1,34 @@ +package com.ahu.ahutong.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CasLoginActionResolverTest { + @Test + fun resolvesRelativeActionAgainstCasDirectory() { + assertEquals( + "https://one.ahu.edu.cn/cas/login?service=campus-card", + resolveCasLoginAction( + "https://one.ahu.edu.cn/cas/login?service=campus-card", + "login?service=campus-card" + ) + ) + } + + @Test + fun preservesAbsoluteCasAction() { + assertEquals( + "https://one.ahu.edu.cn/cas/login;jsessionid=abc?service=card", + resolveCasLoginAction( + "https://one.ahu.edu.cn/cas/login?service=card", + "/cas/login;jsessionid=abc?service=card" + ) + ) + } + + @Test + fun rejectsInvalidPageUrl() { + assertNull(resolveCasLoginAction("not-a-url", "login")) + } +} diff --git a/app/src/test/java/com/ahu/ahutong/data/crawler/net/SessionRefreshPolicyTest.kt b/app/src/test/java/com/ahu/ahutong/data/crawler/net/SessionRefreshPolicyTest.kt new file mode 100644 index 00000000..8db0b194 --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/data/crawler/net/SessionRefreshPolicyTest.kt @@ -0,0 +1,41 @@ +package com.ahu.ahutong.data.crawler.net + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue +import okhttp3.Request +import okhttp3.HttpUrl.Companion.toHttpUrl + +class SessionRefreshPolicyTest { + private val requestUrl = "https://jw.ahu.edu.cn/student/for-std/lesson-search".toHttpUrl() + + @Test + fun `recognizes first party login redirect`() { + assertTrue( + SessionRefreshPolicy.isFirstPartyLoginRedirect( + requestUrl, + "https://one.ahu.edu.cn/cas/login?service=https%3A%2F%2Fjw.ahu.edu.cn" + ) + ) + assertTrue(SessionRefreshPolicy.isFirstPartyLoginRedirect(requestUrl, "/tologin?refer=student")) + } + + @Test + fun `does not refresh for unrelated or external redirects`() { + assertFalse(SessionRefreshPolicy.isFirstPartyLoginRedirect(requestUrl, "/notice?refer=home")) + assertFalse(SessionRefreshPolicy.isFirstPartyLoginRedirect(requestUrl, "https://example.com/login")) + assertFalse(SessionRefreshPolicy.isFirstPartyLoginRedirect(requestUrl, null)) + } + + @Test + fun `request keeps the generation observed when it was dispatched`() { + val generation = SessionRefreshCoordinator.currentGeneration() + val request = Request.Builder().url(requestUrl).build() + val tagged = SessionRefreshCoordinator.tagRequest(request) + + assertEquals(generation, SessionRefreshCoordinator.observedGeneration(tagged)) + assertSame(tagged, SessionRefreshCoordinator.tagRequest(tagged)) + } +} diff --git a/app/src/test/java/com/ahu/ahutong/data/model/AppUiThemeTest.kt b/app/src/test/java/com/ahu/ahutong/data/model/AppUiThemeTest.kt new file mode 100644 index 00000000..ab11006e --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/data/model/AppUiThemeTest.kt @@ -0,0 +1,18 @@ +package com.ahu.ahutong.data.model + +import kotlin.test.Test +import kotlin.test.assertEquals + +class AppUiThemeTest { + @Test + fun `stored theme wins over legacy liquid glass preference`() { + assertEquals(AppUiTheme.MIUIX, AppUiTheme.fromStorage("miuix", false)) + } + + @Test + fun `legacy preference migrates without changing appearance`() { + assertEquals(AppUiTheme.MATERIAL, AppUiTheme.fromStorage(null, false)) + assertEquals(AppUiTheme.LIQUID_GLASS, AppUiTheme.fromStorage(null, true)) + assertEquals(AppUiTheme.LIQUID_GLASS, AppUiTheme.fromStorage(null, null)) + } +} diff --git a/app/src/test/java/com/ahu/ahutong/data/repository/RepositoryIndexRefreshPolicyTest.kt b/app/src/test/java/com/ahu/ahutong/data/repository/RepositoryIndexRefreshPolicyTest.kt new file mode 100644 index 00000000..7e22d483 --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/data/repository/RepositoryIndexRefreshPolicyTest.kt @@ -0,0 +1,83 @@ +package com.ahu.ahutong.data.repository + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RepositoryIndexRefreshPolicyTest { + @Test + fun `fresh compatible index is reused even when UI observes progress`() { + val now = 50_000_000L + + assertTrue( + RepositoryIndexRefreshPolicy.canReuse( + cachedAtMillis = now - RepositoryIndexRefreshPolicy.AUTO_REFRESH_INTERVAL_MS + 1L, + cachedVersion = 7, + expectedVersion = 7, + hasRootContents = true, + nowMillis = now + ) + ) + } + + @Test + fun `stale incompatible or incomplete index is rebuilt`() { + val now = 50_000_000L + val staleAt = now - RepositoryIndexRefreshPolicy.AUTO_REFRESH_INTERVAL_MS + + assertFalse(RepositoryIndexRefreshPolicy.canReuse(staleAt, 7, 7, true, now)) + assertFalse(RepositoryIndexRefreshPolicy.canReuse(now, 6, 7, true, now)) + assertFalse(RepositoryIndexRefreshPolicy.canReuse(now, 7, 7, false, now)) + assertFalse(RepositoryIndexRefreshPolicy.canReuse(now + 1L, 7, 7, true, now)) + } + + @Test + fun `index construction does not eagerly fetch every LFS candidate`() { + val source = File( + repositoryRoot(), + "app/src/main/java/com/ahu/ahutong/data/repository/RepositoryManager.kt" + ).readText() + + assertFalse(source.contains("resolveGitLfsDisplaySizes")) + assertTrue(source.contains("size = child.size")) + } + + @Test + fun `cold root renders before the full repository index finishes`() { + val source = File( + repositoryRoot(), + "app/src/main/java/com/ahu/ahutong/data/repository/RepositoryManager.kt" + ).readText() + val getContents = source.substring( + source.indexOf("suspend fun getContents"), + source.indexOf("suspend fun warmUpAllContentCaches") + ) + + val immediateRootReturn = getContents.indexOf("fallbackRootItems?.let { return@withContext it }") + val indexWarmUp = getContents.indexOf("warmUpAllContentCaches(") + assertTrue(immediateRootReturn in 0 until indexWarmUp) + } + + @Test + fun `repository cache parsing stays off the composition thread`() { + val source = File( + repositoryRoot(), + "app/src/main/java/com/ahu/ahutong/ui/state/RepositoryViewModel.kt" + ).readText() + val stateGetter = source.substring( + source.indexOf("fun getInitialDirectoryState"), + source.indexOf("fun getSharedState") + ) + + assertFalse(stateGetter.contains("RepositoryManager.getCachedContents")) + assertTrue(source.contains("withContext(Dispatchers.IO) { refreshDownloadedSet() }")) + assertTrue(source.contains("val resolvedState = withContext(Dispatchers.IO)")) + } + + private fun repositoryRoot(): File { + val userDirectory = requireNotNull(System.getProperty("user.dir")) + return generateSequence(File(userDirectory)) { it.parentFile } + .first { File(it, "app/src/main/java").isDirectory } + } +} diff --git a/app/src/test/java/com/ahu/ahutong/data/security/NetworkSecurityConfigTest.kt b/app/src/test/java/com/ahu/ahutong/data/security/NetworkSecurityConfigTest.kt new file mode 100644 index 00000000..bbe2c8c1 --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/data/security/NetworkSecurityConfigTest.kt @@ -0,0 +1,25 @@ +package com.ahu.ahutong.data.security + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NetworkSecurityConfigTest { + @Test + fun `cleartext is limited to the in-process loopback bridge`() { + val xml = File(repositoryRoot(), "app/src/main/res/xml/network_security_config.xml") + .readText() + + assertTrue(xml.contains("127.0.0.1")) + assertTrue(xml.contains(">localhost")) + } + + private fun repositoryRoot(): File { + val userDirectory = requireNotNull(System.getProperty("user.dir")) + return generateSequence(File(userDirectory)) { it.parentFile } + .first { File(it, "app/src/main/res").isDirectory } + } +} diff --git a/app/src/test/java/com/ahu/ahutong/data/server/ApkDownloadArchitectureTest.kt b/app/src/test/java/com/ahu/ahutong/data/server/ApkDownloadArchitectureTest.kt new file mode 100644 index 00000000..9480002b --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/data/server/ApkDownloadArchitectureTest.kt @@ -0,0 +1,37 @@ +package com.ahu.ahutong.data.server + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ApkDownloadArchitectureTest { + @Test + fun `mirror switch closes active calls before joining the old download`() { + val viewModel = source("com/ahu/ahutong/ui/state/MainViewModel.kt") + val cancelIndex = viewModel.indexOf("AhuTong.cancelApkDownloads()") + val joinIndex = viewModel.indexOf("previousDownload?.join()") + + assertTrue(cancelIndex >= 0) + assertTrue(joinIndex > cancelIndex) + assertTrue(source("com/ahu/ahutong/data/server/AhuTong.kt").contains("dispatcher.cancelAll()")) + } + + @Test + fun `normal APK finalization hashes the completed part only once`() { + val viewModel = source("com/ahu/ahutong/ui/state/MainViewModel.kt") + + assertEquals(1, Regex("sha256Of\\(partFile\\)").findAll(viewModel).count()) + } + + private fun source(relativePath: String): String = File( + repositoryRoot(), + "app/src/main/java/$relativePath" + ).readText() + + private fun repositoryRoot(): File { + val userDirectory = requireNotNull(System.getProperty("user.dir")) + return generateSequence(File(userDirectory)) { it.parentFile } + .first { File(it, "app/src/main/java").isDirectory } + } +} diff --git a/app/src/test/java/com/ahu/ahutong/data/weather/WeatherR8ContractTest.kt b/app/src/test/java/com/ahu/ahutong/data/weather/WeatherR8ContractTest.kt new file mode 100644 index 00000000..dd11fd26 --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/data/weather/WeatherR8ContractTest.kt @@ -0,0 +1,26 @@ +package com.ahu.ahutong.data.weather + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WeatherR8ContractTest { + @Test + fun `release keeps complete Gson weather contracts`() { + val rules = File(repositoryRoot(), "app/proguard-rules.pro").readText() + + assertTrue(rules.contains("-keep class com.ahu.ahutong.data.weather.** { *; }")) + assertFalse( + rules.contains( + "-keepclassmembers,allowoptimization class com.ahu.ahutong.data.weather.**" + ) + ) + } + + private fun repositoryRoot(): File { + val userDirectory = requireNotNull(System.getProperty("user.dir")) + return generateSequence(File(userDirectory)) { it.parentFile } + .first { File(it, "app/proguard-rules.pro").isFile } + } +} diff --git a/app/src/test/java/com/ahu/ahutong/personalization/AppActionCatalogTest.kt b/app/src/test/java/com/ahu/ahutong/personalization/AppActionCatalogTest.kt index 5cb7e69b..9cd1a2e1 100644 --- a/app/src/test/java/com/ahu/ahutong/personalization/AppActionCatalogTest.kt +++ b/app/src/test/java/com/ahu/ahutong/personalization/AppActionCatalogTest.kt @@ -35,6 +35,14 @@ class AppActionCatalogTest { ) } + @Test + fun recentElectricityRoomsRouteUsesPaymentEntryAction() { + assertEquals( + AppActionId.OPEN_ELECTRICITY_PAYMENT, + AppActionCatalog.actionForRoute("electricity_recent_rooms") + ) + } + @Test fun outputSchemaHasReservedClassesAtEnd() { assertEquals(AppActionCatalog.OTHER_OUTPUT_ID, AppActionCatalog.outputIds.takeLast(2).first()) @@ -61,7 +69,7 @@ class AppActionCatalogTest { repositoryRoot, "app/src/main/java/com/ahu/ahutong/ui/screen/Main.kt" ).readText() - val literalRoutes = Regex("animatedComposable\\(\\\"([^\\\"]+)\\\"") + val literalRoutes = Regex("animatedComposable\\((?:[A-Za-z_][A-Za-z0-9_]*,\\s*)?\\\"([^\\\"]+)\\\"") .findAll(mainSource) .map { it.groupValues[1] } .filterNot { it == "debug" } diff --git a/app/src/test/java/com/ahu/ahutong/personalization/journey/JourneyFailureIsolationTest.kt b/app/src/test/java/com/ahu/ahutong/personalization/journey/JourneyFailureIsolationTest.kt new file mode 100644 index 00000000..cfa2930e --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/personalization/journey/JourneyFailureIsolationTest.kt @@ -0,0 +1,40 @@ +package com.ahu.ahutong.personalization.journey + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class JourneyFailureIsolationTest { + @Test + fun `all emitted journey labels are accepted by the trainer`() { + assertTrue(JourneyTrainingLabelPolicy.accepts(JourneyTrainingLabelPolicy.ORGANIC_JOURNEY)) + assertTrue(JourneyTrainingLabelPolicy.accepts(JourneyTrainingLabelPolicy.INTERVENTION_FREE_TIMEOUT)) + assertTrue(JourneyTrainingLabelPolicy.accepts(JourneyTrainingLabelPolicy.INTERVENTION_FREE_MAX_STEPS)) + assertFalse(JourneyTrainingLabelPolicy.accepts("UNRECOGNIZED")) + } + + @Test + fun `background personalization scopes isolate uncaught failures`() { + val root = repositoryRoot() + val runtime = File( + root, + "app/src/main/java/com/ahu/ahutong/personalization/runtime/PredictionRuntime.kt" + ).readText() + val journey = File( + root, + "app/src/main/java/com/ahu/ahutong/personalization/journey/JourneyPredictionEngine.kt" + ).readText() + + assertTrue(runtime.contains("CoroutineExceptionHandler")) + assertTrue(journey.contains("CoroutineExceptionHandler")) + assertTrue(journey.contains("dwellJobs[pending.journeyId] = scope.launch")) + assertTrue(journey.contains("deadlineJobs[pending.journeyId] = scope.launch")) + } + + private fun repositoryRoot(): File { + val userDirectory = requireNotNull(System.getProperty("user.dir")) + return generateSequence(File(userDirectory)) { it.parentFile } + .first { File(it, "app/src/main/java").isDirectory } + } +} diff --git a/app/src/test/java/com/ahu/ahutong/ui/screen/main/CardPayRequestTest.kt b/app/src/test/java/com/ahu/ahutong/ui/screen/main/CardPayRequestTest.kt new file mode 100644 index 00000000..dc3d6cf8 --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/ui/screen/main/CardPayRequestTest.kt @@ -0,0 +1,47 @@ +package com.ahu.ahutong.ui.screen.main + +import com.ahu.ahutong.data.crawler.model.ycard.CardPayRequest +import com.ahu.ahutong.data.crawler.utils.sha256 +import com.ahu.ahutong.data.model.CardRechargeBank +import org.junit.Assert.assertEquals +import org.junit.Test + +class CardPayRequestTest { + @Test + fun chinaMerchantsBankUsesCapturedNativePaymentChannel() { + val request = CardPayRequest(ORDER_ID, CardRechargeBank.CHINA_MERCHANTS_BANK) + val params = request.toMap() + + assertEquals("PAYMENTCASHIER", params["paytype"]) + assertEquals("81", params["paytypeid"]) + assertEquals(expectedSignature(params), params["SIGN"]) + } + + @Test + fun agriculturalBankKeepsExistingPaymentChannel() { + val request = CardPayRequest(ORDER_ID, CardRechargeBank.AGRICULTURAL_BANK) + val params = request.toMap() + + assertEquals("BANKCARD", params["paytype"]) + assertEquals("63", params["paytypeid"]) + assertEquals(expectedSignature(params), params["SIGN"]) + } + + private fun expectedSignature(params: Map): String = sha256( + "APP_ID=${params["APP_ID"]}" + + "&NONCE=${params["NONCE"]}" + + "&SIGN_TYPE=${params["SIGN_TYPE"]}" + + "&TIMESTAMP=${params["TIMESTAMP"]}" + + "&orderid=$ORDER_ID" + + "&paystep=${params["paystep"]}" + + "&paytype=${params["paytype"]}" + + "&paytypeid=${params["paytypeid"]}" + + "&redirect_url=${params["redirect_url"]}" + + "&userAgent=${params["userAgent"]}" + + "&SECRET_KEY=0osTIhce7uPvDKHz6aa67bhCukaKoYl4" + ).uppercase() + + private companion object { + const val ORDER_ID = "test-order-id" + } +} 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 deleted file mode 100644 index 443544f9..00000000 --- a/app/src/test/java/com/ahu/ahutong/ui/screen/main/CmbRechargePageStyleTest.kt +++ /dev/null @@ -1,206 +0,0 @@ -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]")) - } -} diff --git a/app/src/test/java/com/ahu/ahutong/ui/state/ElectricityControllerTest.kt b/app/src/test/java/com/ahu/ahutong/ui/state/ElectricityControllerTest.kt new file mode 100644 index 00000000..c10808a9 --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/ui/state/ElectricityControllerTest.kt @@ -0,0 +1,28 @@ +package com.ahu.ahutong.ui.state + +import com.ahu.ahutong.data.model.ElectricityController +import org.junit.Assert.assertEquals +import org.junit.Test + +class ElectricityControllerTest { + @Test + fun controllersMatchCapturedFeeItemsAndHierarchyLevels() { + assertEquals( + listOf( + listOf("电控A", "408", "false", "1", "2", "3"), + listOf("电控B", "428", "false", "1", "2", "3"), + listOf("电控C", "488", "true", "2", "3", "4") + ), + ElectricityController.entries.map { controller -> + listOf( + controller.displayName, + controller.feeItemId, + controller.requiresCampus.toString(), + controller.floorLevel, + controller.roomLevel, + controller.roomInfoLevel + ) + } + ) + } +} diff --git a/app/src/test/java/com/ahu/ahutong/ui/state/ExamRefreshPolicyTest.kt b/app/src/test/java/com/ahu/ahutong/ui/state/ExamRefreshPolicyTest.kt new file mode 100644 index 00000000..340e2673 --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/ui/state/ExamRefreshPolicyTest.kt @@ -0,0 +1,50 @@ +package com.ahu.ahutong.ui.state + +import com.ahu.ahutong.data.model.Exam +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ExamRefreshPolicyTest { + @Test + fun `recent exam snapshot skips automatic network refresh`() { + val now = 10_000_000L + + assertFalse( + ExamRefreshPolicy.shouldRefresh( + cachedAtMillis = now - ExamRefreshPolicy.AUTO_REFRESH_INTERVAL_MS + 1L, + nowMillis = now + ) + ) + assertTrue( + ExamRefreshPolicy.shouldRefresh( + cachedAtMillis = now - ExamRefreshPolicy.AUTO_REFRESH_INTERVAL_MS, + nowMillis = now + ) + ) + } + + @Test + fun `missing or future exam timestamp refreshes safely`() { + assertTrue(ExamRefreshPolicy.shouldRefresh(cachedAtMillis = 0L, nowMillis = 10L)) + assertTrue(ExamRefreshPolicy.shouldRefresh(cachedAtMillis = 11L, nowMillis = 10L)) + } + + @Test + fun `exam comparison uses visible values instead of object identity`() { + val first = listOf(exam(course = "高等数学", seat = "18")) + val same = listOf(exam(course = "高等数学", seat = "18")) + val changed = listOf(exam(course = "高等数学", seat = "19")) + + assertTrue(first.hasSameExamContents(same)) + assertFalse(first.hasSameExamContents(changed)) + } + + private fun exam(course: String, seat: String) = Exam().apply { + this.course = course + location = "磬苑校区-博学楼-A101" + time = "2026-09-01 09:00~11:00" + seatNum = seat + finished = false + } +} diff --git a/app/src/test/java/com/ahu/ahutong/ui/state/FreeClassroomQueryPlanningTest.kt b/app/src/test/java/com/ahu/ahutong/ui/state/FreeClassroomQueryPlanningTest.kt new file mode 100644 index 00000000..06c31446 --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/ui/state/FreeClassroomQueryPlanningTest.kt @@ -0,0 +1,22 @@ +package com.ahu.ahutong.ui.state + +import kotlin.test.Test +import kotlin.test.assertEquals + +class FreeClassroomQueryPlanningTest { + @Test + fun `all buildings uses one backend query`() { + assertEquals(listOf(""), freeClassroomBuildingQueries(emptySet())) + } + + @Test + fun `selected buildings and units are normalized`() { + assertEquals(listOf("2", "9"), freeClassroomBuildingQueries(setOf(9, 2))) + assertEquals(listOf("1", "5", "13"), freeClassroomUnits(setOf(13, 5, 1, 99))) + } + + @Test + fun `no units means all thirteen periods`() { + assertEquals((1..13).map(Int::toString), freeClassroomUnits(emptySet())) + } +} diff --git a/app/src/test/java/com/ahu/ahutong/ui/state/ScheduleTimeRangeTest.kt b/app/src/test/java/com/ahu/ahutong/ui/state/ScheduleTimeRangeTest.kt new file mode 100644 index 00000000..fe08c643 --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/ui/state/ScheduleTimeRangeTest.kt @@ -0,0 +1,27 @@ +package com.ahu.ahutong.ui.state + +import com.ahu.ahutong.data.model.Course +import kotlin.test.Test +import kotlin.test.assertEquals + +class ScheduleTimeRangeTest { + @Test + fun `single section uses its exact clock range`() { + val course = Course().apply { + setStartTime("1") + setLength("1") + } + + assertEquals(8 * 60..8 * 60 + 45, ScheduleViewModel.getCourseTimeRangeInMinutes(course)) + } + + @Test + fun `multi section course ends at the last section`() { + val course = Course().apply { + setStartTime("4") + setLength("3") + } + + assertEquals(10 * 60 + 40..14 * 60 + 45, ScheduleViewModel.getCourseTimeRangeInMinutes(course)) + } +} diff --git a/app/src/test/java/com/ahu/ahutong/ui/theme/LiquidGlassArchitectureTest.kt b/app/src/test/java/com/ahu/ahutong/ui/theme/LiquidGlassArchitectureTest.kt new file mode 100644 index 00000000..b712eecc --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/ui/theme/LiquidGlassArchitectureTest.kt @@ -0,0 +1,88 @@ +package com.ahu.ahutong.ui.theme + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LiquidGlassArchitectureTest { + @Test + fun `main owns the global backdrop host and conditional content capture`() { + val main = source("com/ahu/ahutong/ui/screen/Main.kt") + val surface = source("com/ahu/ahutong/ui/components/LiquidGlassSurface.kt") + + assertTrue(main.contains("LiquidGlassAppHost(modifier = Modifier.fillMaxSize())")) + assertTrue(main.contains(".captureLiquidGlassContent()")) + assertFalse(main.contains("rememberLayerBackdrop()")) + assertTrue(surface.contains("tokens.quality.supportsBackdrop")) + assertTrue(surface.contains("LocalLiquidGlassAmbientBackdrop provides ambientBackdrop")) + assertTrue(surface.contains("LocalLiquidGlassContentBackdrop provides contentBackdrop")) + assertTrue(surface.contains("else if (tokens.enabled)")) + assertTrue(surface.contains("LiquidGlassQuality.Tinted")) + } + + @Test + fun `settings use the shared surface instead of a private glass implementation`() { + val settings = source("com/ahu/ahutong/ui/components/SettingsComponents.kt") + val sharedComponents = source("com/ahu/ahutong/ui/components/AppComponents.kt") + + assertTrue(settings.contains(".appLiquidGlassSurface(")) + assertTrue(settings.contains("LocalLiquidGlassAmbientBackdrop.current")) + assertTrue(sharedComponents.contains("backdropSamplingEnabled = false")) + assertFalse(settings.contains("private fun Modifier.liquidGlassSurface")) + assertFalse(settings.contains("rememberLayerBackdrop()")) + } + + @Test + fun `theme selection is gated until persisted state is ready`() { + val viewModel = source("com/ahu/ahutong/ui/state/PreferencesViewModel.kt") + val theme = source("com/ahu/ahutong/ui/theme/AHUTheme.kt") + val preferences = source("com/ahu/ahutong/ui/screen/settings/Preferences.kt") + val local = source("com/ahu/ahutong/ui/components/LocalIsLiquidGlassEnabled.kt") + + assertTrue(viewModel.contains("_isUiThemePreferenceReady = MutableStateFlow(startupThemePreferences != null)")) + assertTrue(viewModel.contains("_isUiThemePreferenceReady.value = true")) + assertTrue(theme.contains("appUiTheme == AppUiTheme.LIQUID_GLASS")) + assertTrue(theme.contains("MiuixTheme(controller = miuixController)")) + assertTrue(theme.contains("ColorSchemeMode.MonetLight")) + assertTrue(theme.contains("ColorSchemeMode.MonetDark")) + assertTrue(preferences.contains("showMiuixDefault = appUiTheme == AppUiTheme.MIUIX")) + assertTrue(local.contains("LocalAppUiTheme")) + assertTrue(local.contains("compositionLocalOf { false }")) + } + + @Test + fun `glass controls honor policy and expose adjustable selection semantics`() { + val button = source("com/ahu/ahutong/ui/components/LiquidButton.kt") + val slider = source("com/ahu/ahutong/ui/components/LiquidSlider.kt") + val toggle = source("com/ahu/ahutong/ui/components/LiquidToggle.kt") + val tabs = source("com/ahu/ahutong/ui/components/LiquidBottomTabs.kt") + val tab = source("com/ahu/ahutong/ui/components/LiquidBottomTab.kt") + + assertTrue(button.contains("LocalLiquidGlassTokens.current")) + assertTrue(button.contains("tokens.quality.supportsRefraction")) + assertTrue(slider.contains("!tokens.quality.supportsBlur")) + assertTrue(slider.contains("setProgress { requestedValue ->")) + assertTrue(slider.contains("heightIn(min = 48.dp)")) + assertFalse(slider.contains("isSystemInDarkTheme")) + assertTrue(toggle.contains("if (!tokens.quality.supportsBlur)")) + assertTrue(toggle.contains("heightIn(min = 48.dp)")) + assertTrue(toggle.contains("toggleableState = if (currentSelected.value())")) + assertTrue(toggle.contains("currentOnSelect.value(!currentSelected.value())")) + assertTrue(tabs.contains("tokens.floating.legacyTint")) + assertTrue(tabs.contains(".selectableGroup()")) + assertTrue(tab.contains(".selectable(")) + assertTrue(tab.contains("selected = selected")) + } + + private fun source(relativePath: String): String = File( + repositoryRoot(), + "app/src/main/java/$relativePath" + ).readText() + + private fun repositoryRoot(): File { + val userDirectory = requireNotNull(System.getProperty("user.dir")) + return generateSequence(File(userDirectory)) { it.parentFile } + .first { File(it, "app/src/main/java").isDirectory } + } +} diff --git a/app/src/test/java/com/ahu/ahutong/ui/theme/LiquidGlassPolicyTest.kt b/app/src/test/java/com/ahu/ahutong/ui/theme/LiquidGlassPolicyTest.kt new file mode 100644 index 00000000..fd74734d --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/ui/theme/LiquidGlassPolicyTest.kt @@ -0,0 +1,38 @@ +package com.ahu.ahutong.ui.theme + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LiquidGlassPolicyTest { + @Test + fun `disabled preference always uses stable material fallback`() { + listOf(26, 30, 31, 32, 33, 36).forEach { sdkInt -> + assertEquals( + LiquidGlassQuality.Disabled, + resolveLiquidGlassQuality(enabled = false, sdkInt = sdkInt) + ) + } + } + + @Test + fun `enabled preference selects capability safe quality by sdk`() { + assertEquals(LiquidGlassQuality.Tinted, resolveLiquidGlassQuality(true, 26)) + assertEquals(LiquidGlassQuality.Tinted, resolveLiquidGlassQuality(true, 30)) + assertEquals(LiquidGlassQuality.Blurred, resolveLiquidGlassQuality(true, 31)) + assertEquals(LiquidGlassQuality.Blurred, resolveLiquidGlassQuality(true, 32)) + assertEquals(LiquidGlassQuality.Refractive, resolveLiquidGlassQuality(true, 33)) + assertEquals(LiquidGlassQuality.Refractive, resolveLiquidGlassQuality(true, 36)) + } + + @Test + fun `only supported qualities capture blur or refract`() { + assertFalse(LiquidGlassQuality.Tinted.supportsBackdrop) + assertFalse(LiquidGlassQuality.Tinted.supportsBlur) + assertFalse(LiquidGlassQuality.Blurred.supportsRefraction) + assertTrue(LiquidGlassQuality.Blurred.supportsBackdrop) + assertTrue(LiquidGlassQuality.Blurred.supportsBlur) + assertTrue(LiquidGlassQuality.Refractive.supportsRefraction) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1cc3f172..2eccaad2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,8 @@ jsoup = "1.19.1" activityCompose = "1.11.0" loggingInterceptorVersion = "5.1.0" mmkvStatic = "2.2.2" -monetVersion = "0.1.0-alpha03" +monetVersion = "0.1.0-alpha03" +miuix = "0.7.2" navigationCompose = "2.9.5" persistentcookiejar = "v1.0.1" retrofitVersion = "2.11.0" @@ -56,7 +57,8 @@ kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib" } logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptorVersion" } -material3 = { module = "androidx.compose.material3:material3" } +material3 = { module = "androidx.compose.material3:material3" } +miuix-android = { module = "top.yukonga.miuix.kmp:miuix-android", version.ref = "miuix" } mmkv-static = { module = "com.tencent:mmkv-static", version.ref = "mmkvStatic" } monet = { module = "com.github.Kyant0:Monet", version.ref = "monetVersion" } persistentcookiejar = { module = "com.github.franmontiel:PersistentCookieJar", version.ref = "persistentcookiejar" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml new file mode 100644 index 00000000..4095b01d --- /dev/null +++ b/gradle/verification-metadata.xml @@ -0,0 +1,5365 @@ + + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index bad7c246..bcea9a72 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip +distributionSha256Sum=df67a32e86e3276d011735facb1535f64d0d88df84fa87521e90becc2d735444 networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle.kts b/settings.gradle.kts index ed2472a9..b383de9f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,15 +9,6 @@ pluginManagement { } mavenCentral() gradlePluginPortal() - maven("https://maven.aliyun.com/repository/google") { - content { - includeGroupByRegex("com\\.android.*") - includeGroupByRegex("com\\.google.*") - includeGroupByRegex("androidx.*") - } - } - maven("https://maven.aliyun.com/repository/gradle-plugin") - maven("https://maven.aliyun.com/repository/public") } } dependencyResolutionManagement { @@ -25,10 +16,13 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - maven("https://jitpack.io") - maven("https://maven.aliyun.com/repository/google") - maven("https://maven.aliyun.com/repository/central") - maven("https://maven.aliyun.com/repository/public") + exclusiveContent { + forRepository { maven("https://jitpack.io") } + filter { + includeGroup("com.github.Kyant0") + includeGroup("com.github.franmontiel") + } + } } }