From 6746cb96456362af8885043b3cc0c23dcbc275d4 Mon Sep 17 00:00:00 2001 From: InChange-Jiang <316875401+InChange-Jiang@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:29:33 +0800 Subject: [PATCH 01/29] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20xuexiaotong?= =?UTF-8?q?=20=E6=A8=A1=E5=9D=97=E4=B8=8E=20reminder=20=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 xuexiaotong 模块(data/UI)及 reminder 模块 更新依赖版本与构建配置 --- app/build.gradle.kts | 3 +- app/src/main/AndroidManifest.xml | 18 + .../java/com/ahu/ahutong/AHUApplication.java | 8 + .../com/ahu/ahutong/data/xuexiaotong/Aes.kt | 24 + .../ahutong/data/xuexiaotong/ChaoxingApi.kt | 468 ++++++++ .../data/xuexiaotong/CredentialCrypto.kt | 62 + .../ahu/ahutong/data/xuexiaotong/Models.kt | 101 ++ .../data/xuexiaotong/PersistentCookieJar.kt | 114 ++ .../com/ahu/ahutong/data/xuexiaotong/Store.kt | 163 +++ .../telemetry/TelemetryV3Models.kt | 4 +- .../com/ahu/ahutong/reminder/AlarmReceiver.kt | 21 + .../com/ahu/ahutong/reminder/BootReceiver.kt | 19 + .../ahu/ahutong/reminder/ReminderScheduler.kt | 194 +++ .../java/com/ahu/ahutong/ui/screen/Main.kt | 10 + .../ui/screen/main/home/HomeWidgetRegistry.kt | 7 + .../ui/screen/xuexiaotong/AddEventDialog.kt | 377 ++++++ .../ui/screen/xuexiaotong/CalendarModel.kt | 213 ++++ .../ui/screen/xuexiaotong/GlassComponents.kt | 91 ++ .../ui/screen/xuexiaotong/LiquidDockAnim.kt | 197 +++ .../ui/screen/xuexiaotong/RemindDialog.kt | 247 ++++ .../ui/screen/xuexiaotong/WorkDetailDialog.kt | 228 ++++ .../xuexiaotong/XuexiaotongLoginScreen.kt | 170 +++ .../screen/xuexiaotong/XuexiaotongScreen.kt | 1067 +++++++++++++++++ .../xuexiaotong/XuexiaotongViewModel.kt | 217 ++++ .../main/res/drawable/ic_calendar_check.xml | 9 + app/src/main/res/drawable/ic_xuexiaotong.png | Bin 0 -> 372837 bytes .../TelemetryPayloadPrivacyTest.kt | 8 + build.gradle.kts | 3 +- gradle/libs.versions.toml | 3 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 30 files changed, 4042 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/ahu/ahutong/data/xuexiaotong/Aes.kt create mode 100644 app/src/main/java/com/ahu/ahutong/data/xuexiaotong/ChaoxingApi.kt create mode 100644 app/src/main/java/com/ahu/ahutong/data/xuexiaotong/CredentialCrypto.kt create mode 100644 app/src/main/java/com/ahu/ahutong/data/xuexiaotong/Models.kt create mode 100644 app/src/main/java/com/ahu/ahutong/data/xuexiaotong/PersistentCookieJar.kt create mode 100644 app/src/main/java/com/ahu/ahutong/data/xuexiaotong/Store.kt create mode 100644 app/src/main/java/com/ahu/ahutong/reminder/AlarmReceiver.kt create mode 100644 app/src/main/java/com/ahu/ahutong/reminder/BootReceiver.kt create mode 100644 app/src/main/java/com/ahu/ahutong/reminder/ReminderScheduler.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/AddEventDialog.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/CalendarModel.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/GlassComponents.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/LiquidDockAnim.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/RemindDialog.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/WorkDetailDialog.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongLoginScreen.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongScreen.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongViewModel.kt create mode 100644 app/src/main/res/drawable/ic_calendar_check.xml create mode 100644 app/src/main/res/drawable/ic_xuexiaotong.png diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8bd72cfa..1b3df162 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") id("com.google.devtools.ksp") id("com.google.dagger.hilt.android") } @@ -34,7 +35,7 @@ android { applicationId = "com.ahu.ahutong" minSdk = 26 targetSdk = 36 - versionCode = 322 + versionCode = 323 versionName = "3.2.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" ndk { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d5adc922..087e79a0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -150,6 +150,24 @@ + + + + + + + + + + + + + diff --git a/app/src/main/java/com/ahu/ahutong/AHUApplication.java b/app/src/main/java/com/ahu/ahutong/AHUApplication.java index ee16cbe1..6d92e1b8 100644 --- a/app/src/main/java/com/ahu/ahutong/AHUApplication.java +++ b/app/src/main/java/com/ahu/ahutong/AHUApplication.java @@ -11,6 +11,8 @@ import com.tencent.bugly.crashreport.CrashReport; import com.ahu.ahutong.data.AHURepository; import com.ahu.ahutong.data.dao.AHUCache; +import com.ahu.ahutong.data.xuexiaotong.Store; +import com.ahu.ahutong.reminder.ReminderScheduler; import com.ahu.ahutong.notification.CourseReminderScheduler; import org.json.JSONObject; @@ -45,6 +47,12 @@ public void onCreate() { super.onCreate(); CrashReport.initCrashReport(this, "2c2ccadcad", BuildConfig.DEBUG); + + // 学习通日历初始化 + Store.INSTANCE.init(this); + ReminderScheduler.INSTANCE.ensureChannel(this); + ReminderScheduler.INSTANCE.scheduleAll(this); + CourseReminderScheduler.INSTANCE.createNotificationChannel(this); CourseReminderScheduler.INSTANCE.reschedule(this); diff --git a/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/Aes.kt b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/Aes.kt new file mode 100644 index 00000000..c9f599fb --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/Aes.kt @@ -0,0 +1,24 @@ +package com.ahu.ahutong.data.xuexiaotong + +import android.util.Base64 +import java.nio.charset.StandardCharsets +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +object Aes { + private const val ALGORITHM = "AES" + private const val TRANSFORMATION = "AES/CBC/PKCS5Padding" + const val CX_AES_KEY = "u2oh6Vu^HWe4_AES" + + fun encrypt(message: String, key: String = CX_AES_KEY): String { + val keyBytes = key.toByteArray(StandardCharsets.UTF_8) + val keySpec = SecretKeySpec(keyBytes, ALGORITHM) + val ivSpec = IvParameterSpec(keyBytes) + + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec) + val encrypted = cipher.doFinal(message.toByteArray(StandardCharsets.UTF_8)) + return Base64.encodeToString(encrypted, Base64.NO_WRAP) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/ChaoxingApi.kt b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/ChaoxingApi.kt new file mode 100644 index 00000000..9cae5abf --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/ChaoxingApi.kt @@ -0,0 +1,468 @@ +package com.ahu.ahutong.data.xuexiaotong + +import android.content.Context +import okhttp3.FormBody +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +class ChaoxingApi(private val context: Context) { + + private val UA = "Mozilla/5.0 (Linux; Android 10; HD1910) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36" + + private val cookieJar = PersistentCookieJar(context) + + private val client: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) + .cookieJar(cookieJar) + .build() + + fun hasSession(): Boolean = Store.hasCookie() + + fun clearSession() { + Store.clearCookie() + cookieJar.clear() + } + + suspend fun loginByPassword(phone: String, pwd: String): String = withContext(Dispatchers.IO) { + val uname = Aes.encrypt(phone.trim()) + val password = Aes.encrypt(pwd) + + val form = FormBody.Builder() + .add("fid", "-1") + .add("uname", uname) + .add("password", password) + .add("refer", "http%3A%2F%2Fi.mooc.chaoxing.com") + .add("t", "true") + .add("forbidotherlogin", "0") + .add("validate", "") + .add("doubleFactorLogin", "0") + .add("independentId", "0") + .add("independentNameId", "0") + .build() + + val req = Request.Builder() + .url("https://passport2.chaoxing.com/fanyalogin") + .post(form) + .header("User-Agent", UA) + .header("Referer", "https://passport2.chaoxing.com/login") + .header("Accept", "application/json, text/javascript, */*; q=0.01") + .build() + + client.newCall(req).execute().use { res -> + val body = res.body?.string() ?: throw IOException("登录接口返回空") + val data = try { JSONObject(body) } catch (e: Exception) { throw IOException("登录接口返回异常") } + if (!data.optBoolean("status", false)) { + val msg = data.optString("msg2", data.optString("msg", "用户名或密码错误")) + throw IOException(msg) + } + val jumpUrl = data.optString("url", "") + try { if (jumpUrl.isNotEmpty()) get(jumpUrl) } catch (e: Exception) { } + val domains = listOf( + "https://mooc2-ans.chaoxing.com/visit/interaction", + "https://mooc1.chaoxing.com/visit/interaction", + "https://mobilelearn.chaoxing.com/page/active/stuActiveList?courseid=1&clazzid=1&cpi=1&ut=s&t=${System.currentTimeMillis()}&stuenc=1&fid=1", + "https://i.mooc.chaoxing.com/space/index", + "https://passport2-api.chaoxing.com/", + "https://stat2-ans.chaoxing.com/" + ) + for (d in domains) { try { get(d) } catch (e: Exception) { } } + + val cookie = cookieJar.cookieString() + Store.saveCookie(cookie) + cookie + } + } + + suspend fun silentRelogin(): Boolean = withContext(Dispatchers.IO) { + val cred = Store.getCredential() ?: return@withContext false + val backup = Store.getCookie() + clearSession() + try { + loginByPassword(cred.first, cred.second) + true + } catch (e: Exception) { + if (backup.isNotEmpty()) { + cookieJar.restoreFromString(backup) + Store.saveCookie(backup) + } + false + } + } + + suspend fun checkLogin(): Boolean = withContext(Dispatchers.IO) { + try { + val html = postText( + "https://mooc2-ans.chaoxing.com/mooc2-ans/visit/courselistdata", + "courseType=1&courseFolderId=0&query=&superstarClass=0&courseFolderSize=0", + referer = "https://mooc2-ans.chaoxing.com/visit/interaction" + ) + html.contains("course-list") || html.contains("learnCourse") || html.contains("course clearfix") + } catch (e: Exception) { false } + } + + private fun baseHeaders(referer: String?): Map { + val h = mutableMapOf( + "Accept" to "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language" to "zh-CN,zh;q=0.9", + "User-Agent" to UA + ) + if (referer != null) h["Referer"] = referer + return h + } + + private suspend fun get(url: String, referer: String? = null): Response = withContext(Dispatchers.IO) { + val req = Request.Builder().url(url).get().apply { + baseHeaders(referer).forEach { (k, v) -> header(k, v) } + }.build() + client.newCall(req).execute() + } + + private suspend fun getText(url: String, referer: String? = null): String = withContext(Dispatchers.IO) { + val res = get(url, referer) + res.use { r -> + if (r.code in 200..299) r.body?.string() ?: "" else throw IOException("HTTP ${r.code} $url") + } + } + + private suspend fun postText(url: String, body: String, referer: String? = null): String = + withContext(Dispatchers.IO) { + val req = Request.Builder().url(url) + .post(body.toRequestBody("application/x-www-form-urlencoded".toMediaType())) + .apply { + baseHeaders(referer).forEach { (k, v) -> header(k, v) } + }.build() + client.newCall(req).execute().use { r -> + if (r.code in 200..299) r.body?.string() ?: "" else throw IOException("HTTP ${r.code} $url") + } + } + + private suspend fun postForm(url: String, form: FormBody, referer: String? = null): String = + withContext(Dispatchers.IO) { + val req = Request.Builder().url(url) + .post(form) + .apply { + baseHeaders(referer).forEach { (k, v) -> header(k, v) } + }.build() + client.newCall(req).execute().use { r -> + if (r.code in 200..299) r.body?.string() ?: "" else throw IOException("HTTP ${r.code} $url") + } + } + + suspend fun fetchCourses(): List = withContext(Dispatchers.IO) { + val form = FormBody.Builder() + .add("courseType", "1") + .add("courseFolderId", "0") + .add("query", "") + .add("superstarClass", "0") + .add("courseFolderSize", "0") + .build() + val html = postForm( + "https://mooc2-ans.chaoxing.com/mooc2-ans/visit/courselistdata", + form, + "https://mooc2-ans.chaoxing.com/visit/interaction" + ) + + val courses = mutableListOf() + val blocks = html.split("
]*title="([^"]*)"""").find(block) + ?.groupValues?.get(1) + ?: Regex("""title="([^"]*)"[^>]*class="course-name""").find(block)?.groupValues?.get(1) + if (name.isNullOrEmpty()) continue + name = name.trim() + + val href = Regex("""href="(https?://mooc1\.chaoxing\.com/visit/stucoursemiddle[^"]*)"""").find(block) + ?.groupValues?.get(1) ?: "" + val courseId = Regex("""class="courseId"[^>]*value="(\d+)"""").find(block) + ?.groupValues?.get(1) ?: "" + + courses.add(Course(courseId, clazzId, cpi, name, href)) + } + + if (courses.isNotEmpty()) Store.saveCourses(courses) + courses + } + + suspend fun fetchCourseKeys(course: Course): CourseKeys = withContext(Dispatchers.IO) { + val url = "https://mooc1.chaoxing.com/visit/stucoursemiddle?courseid=${course.courseId}" + + "&clazzid=${course.clazzId}&cpi=${course.cpi}&ismooc2=1&v=2" + + var html: String + var enc: String + var workEnc: String + try { + val res = get(url, "https://mooc2-ans.chaoxing.com/visit/interaction") + val finalUrl = res.request.url.toString() + html = res.use { r -> + if (r.code in 200..299) r.body?.string() ?: "" else throw IOException("HTTP ${r.code} $url") + } + enc = Regex("""[?&]enc=([a-f0-9]{32})""", RegexOption.IGNORE_CASE).find(finalUrl)?.groupValues?.get(1) ?: "" + workEnc = extractHiddenValue(html, "workEnc") + } catch (e: Exception) { + html = ""; enc = ""; workEnc = "" + } + + if (enc.isEmpty() && html.isNotEmpty()) { + enc = Regex("""enc=([a-f0-9]{32})""", RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1) ?: "" + } + + if (enc.isEmpty() || workEnc.isEmpty()) { + try { + getText("https://mooc2-ans.chaoxing.com/visit/interaction") + val res2 = get(url, "https://mooc2-ans.chaoxing.com/visit/interaction") + val finalUrl2 = res2.request.url.toString() + html = res2.use { r -> r.body?.string() ?: "" } + enc = Regex("""[?&]enc=([a-f0-9]{32})""", RegexOption.IGNORE_CASE).find(finalUrl2)?.groupValues?.get(1) ?: "" + if (enc.isEmpty()) { + enc = Regex("""enc=([a-f0-9]{32})""", RegexOption.IGNORE_CASE).find(html)?.groupValues?.get(1) ?: "" + } + workEnc = extractHiddenValue(html, "workEnc") + } catch (e: Exception) { } + } + + if (workEnc.isEmpty() && enc.isNotEmpty()) { + try { + val listUrl = "https://mooc1.chaoxing.com/mooc2/work/list?courseId=${course.courseId}" + + "&classId=${course.clazzId}&cpi=${course.cpi}&ut=s&t=${System.currentTimeMillis()}" + + "&stuenc=$enc&enc=&status=0&pageNum=1" + val listHtml = getText(listUrl, "https://mooc2-ans.chaoxing.com/mooc2-ans/mycourse/stu") + workEnc = Regex("""enc=([a-f0-9]{32})""", RegexOption.IGNORE_CASE).find(listHtml)?.groupValues?.get(1) ?: "" + } catch (e: Exception) { } + } + + if (enc.isEmpty() || workEnc.isEmpty()) { + throw IOException("课程 ${course.name} 密钥解析失败") + } + CourseKeys(enc, workEnc) + } + + private fun extractHiddenValue(html: String, id: String): String { + val re = Regex("""]*id=["']?$id["']?[^>]*value=["']([^"']*)["']""", RegexOption.IGNORE_CASE) + val m = re.find(html) + if (m != null) return m.groupValues[1] + val re2 = Regex("""]*value=["']([^"']*)["'][^>]*id=["']?$id["']?""", RegexOption.IGNORE_CASE) + return re2.find(html)?.groupValues?.get(1) ?: "" + } + + suspend fun fetchCourseWorks(course: Course, keys: CourseKeys): List = + withContext(Dispatchers.IO) { + val works = mutableListOf() + val baseParams = "courseId=${course.courseId}&classId=${course.clazzId}&cpi=${course.cpi}" + + "&ut=s&t=${System.currentTimeMillis()}&stuenc=${keys.enc}&enc=${keys.workEnc}" + + var pageNum = 1 + var totalPages = 1 + var hasMore = true + + while (hasMore && pageNum <= 50) { + val url = "https://mooc1.chaoxing.com/mooc2/work/list?$baseParams&status=0&pageNum=$pageNum" + val referer = "https://mooc2-ans.chaoxing.com/mooc2-ans/mycourse/stu?courseid=${course.courseId}" + + "&clazzid=${course.clazzId}&cpi=${course.cpi}" + val html = getText(url, referer) + + if (pageNum == 1) { + val totalMatch = Regex("""共(\d+)页""", RegexOption.IGNORE_CASE).find(html) + ?: Regex("""totalPage['":\s]+(\d+)""", RegexOption.IGNORE_CASE).find(html) + if (totalMatch != null) { + totalPages = totalMatch.groupValues[1].toIntOrNull() ?: 1 + } else if (!html.contains("work/task")) { + hasMore = false; break + } + } + + val liRe = Regex( + """]*data="(https?://mooc1\.chaoxing\.com/mooc-ans/mooc2/work/task[^"]*)"[^>]*>([\s\S]*?)""" + ) + var pageWorks = 0 + for (m in liRe.findAll(html)) { + var detailUrl = m.groupValues[1].replace("&", "&") + val liContent = m.groupValues[2] + + var title = Regex("""]*class="[^"]*overHidden2[^"]*"[^>]*>([^<]*)

""").find(liContent) + ?.groupValues?.get(1)?.trim() + if (title.isNullOrEmpty()) { + title = Regex("""aria-label="([^"]*?)"""").find(m.value) + ?.groupValues?.get(1)?.split(";")?.firstOrNull()?.trim() + } + if (title.isNullOrEmpty()) title = "未命名作业" + + val statusRaw = Regex("""]*class="[^"]*status[^"]*"[^>]*>([\s\S]*?)

""").find(liContent) + ?.groupValues?.get(1)?.replace(Regex("""<[^>]+>"""), "")?.trim() ?: "" + + val workId = Regex("""workId=(\d+)""").find(detailUrl)?.groupValues?.get(1) ?: "" + val answerId = Regex("""answerId=(\d+)""").find(detailUrl)?.groupValues?.get(1) ?: "" + + val color = Store.getCourseColorCached(course.courseId) + ?: CourseColors.byCourseId(course.courseId) + + works.add( + Work( + workId = workId, answerId = answerId, courseId = course.courseId, + courseName = course.name, title = title, status = statusRaw, + detailUrl = detailUrl, colorBg = color.bg, colorText = color.text + ) + ) + pageWorks++ + } + + if (pageWorks == 0) { hasMore = false; break } + if (pageNum >= totalPages) { hasMore = false } else { pageNum++; delay(800) } + } + works + } + + suspend fun fetchWorkDeadline(work: Work): Pair? = withContext(Dispatchers.IO) { + val url = work.detailUrl + val referer = "https://mooc1.chaoxing.com/mooc2/work/list?courseId=${work.courseId}" + val html = getText(url, referer) + + val timeRe = Regex("""作答时间:\s*([\d-]+\s[\d:]+)\s*至\s*([\d-]+\s[\d:]+)""") + val m1 = timeRe.find(html) + if (m1 != null) { + val start = parseMMDD(m1.groupValues[1]) + val end = parseMMDD(m1.groupValues[2]) + if (start != null && end != null) return@withContext Pair(start, end) + } + val plainRe = Regex("""作答时间:\s*([\d-]+\s[\d:]+)\s*至\s*([\d-]+\s[\d:]+)""") + val m2 = plainRe.find(html) + if (m2 != null) { + val start = parseMMDD(m2.groupValues[1]) + val end = parseMMDD(m2.groupValues[2]) + if (start != null && end != null) return@withContext Pair(start, end) + } + null + } + + private fun parseMMDD(str: String): Long? { + val m = Regex("""^(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{2})$""").find(str) ?: return null + val month = m.groupValues[1].toInt() + val day = m.groupValues[2].toInt() + val hour = m.groupValues[3].toInt() + val minute = m.groupValues[4].toInt() + return try { + java.util.Calendar.getInstance().apply { + set(java.util.Calendar.MONTH, month - 1) + set(java.util.Calendar.DAY_OF_MONTH, day) + set(java.util.Calendar.HOUR_OF_DAY, hour) + set(java.util.Calendar.MINUTE, minute) + set(java.util.Calendar.SECOND, 0) + set(java.util.Calendar.MILLISECOND, 0) + }.timeInMillis + } catch (e: Exception) { null } + } + + interface ProgressListener { + fun onProgress(done: Int, total: Int, message: String) + } + + suspend fun syncAllWorks(listener: ProgressListener? = null): List = + withContext(Dispatchers.IO) { + listener?.onProgress(0, 0, "正在获取课程列表...") + var courses = Store.getCourses() + if (courses.isEmpty()) courses = fetchCourses() + if (courses.isEmpty()) throw IOException("课程列表获取为空,请稍后重试") + + val existing = Store.getWorks() + val existingMap = existing.filter { it.workId.isNotEmpty() && it.endTs != null } + .associateBy { it.workId } + + val allWorks = mutableListOf() + var done = 0 + val total = courses.size + + for (course in courses) { + listener?.onProgress(done, total, "正在处理:${course.name}") + try { + val keys = fetchCourseKeys(course) + delay(600) + val works = fetchCourseWorks(course, keys) + delay(600) + + for (work in works) { + val prev = existingMap[work.workId] + var startTs: Long? = prev?.startTs + var endTs: Long? = prev?.endTs + + if (prev == null) { + try { + val dl = fetchWorkDeadline(work) + if (dl != null) { startTs = dl.first; endTs = dl.second } + delay(800) + } catch (e: Exception) { } + } + + allWorks.add(work.copy(startTs = startTs, endTs = endTs, + rawStart = prev?.rawStart ?: "", rawEnd = prev?.rawEnd ?: "")) + } + } catch (e: Exception) { } + done++ + listener?.onProgress(done, total, "已完成 $done/$total 门课程") + } + + if (allWorks.isEmpty() && Store.getWorks().isNotEmpty()) { + throw IOException("同步结果为空,已保留本地数据,请稍后重试") + } + + Store.saveWorks(allWorks) + Store.saveLastSync(System.currentTimeMillis()) + listener?.onProgress(total, total, "同步完成") + allWorks + } + + suspend fun syncCourseProgress(listener: ProgressListener? = null): List = + withContext(Dispatchers.IO) { + listener?.onProgress(0, 0, "正在获取课程进度...") + var courses = Store.getCourses() + if (courses.isEmpty()) courses = fetchCourses() + if (courses.isEmpty()) throw IOException("课程列表获取为空,请稍后重试") + + val result = mutableListOf() + var done = 0 + val total = courses.size + + for (course in courses) { + listener?.onProgress(done, total, "正在处理:${course.name}") + try { + val url = "https://mooc2-ans.chaoxing.com/mooc2-ans/mycourse/studentcourse" + + "?courseid=${course.courseId}&clazzid=${course.clazzId}&cpi=${course.cpi}&ut=s" + val html = getText(url, "https://mooc2-ans.chaoxing.com/visit/interaction") + val m = Regex("""已完成任务点[\s\S]*?]*>(\d+)\s*/\s*(\d+)""", RegexOption.IGNORE_CASE) + .find(html) + if (m != null) { + val finish = m.groupValues[1].toIntOrNull() ?: 0 + val jobcount = m.groupValues[2].toIntOrNull() ?: 0 + val percent = if (jobcount > 0) (finish * 100 / jobcount) else 0 + result.add(CourseProgress(course.courseId, course.clazzId, course.cpi, + course.name, finish, jobcount, percent, System.currentTimeMillis())) + } + delay(800) + } catch (e: Exception) { } + done++ + listener?.onProgress(done, total, "已完成 $done/$total 门课程") + } + + if (result.isEmpty() && Store.getCourseProgress().isNotEmpty()) { + throw IOException("课程进度获取为空,已保留本地数据,请稍后重试") + } + Store.saveCourseProgress(result) + listener?.onProgress(total, total, "同步完成") + result + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/CredentialCrypto.kt b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/CredentialCrypto.kt new file mode 100644 index 00000000..013dc24c --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/CredentialCrypto.kt @@ -0,0 +1,62 @@ +package com.ahu.ahutong.data.xuexiaotong + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +object CredentialCrypto { + private const val ALIAS = "ahutong_credential_key" + private const val KEYSTORE = "AndroidKeyStore" + private const val TRANSFORM = "AES/GCM/NoPadding" + private const val IV_LEN = 12 + private const val TAG_BITS = 128 + + private fun getOrCreateKey(): SecretKey { + val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) } + (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey } + val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) + gen.init( + KeyGenParameterSpec.Builder( + ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build() + ) + return gen.generateKey() + } + + fun encrypt(plain: String): String { + val key = getOrCreateKey() + val cipher = Cipher.getInstance(TRANSFORM) + cipher.init(Cipher.ENCRYPT_MODE, key) + val ct = cipher.doFinal(plain.toByteArray(Charsets.UTF_8)) + return Base64.encodeToString(cipher.iv + ct, Base64.NO_WRAP) + } + + fun decrypt(data: String): String? { + return try { + val bytes = Base64.decode(data, Base64.NO_WRAP) + if (bytes.size <= IV_LEN) { + null + } else { + val key = getOrCreateKey() + val cipher = Cipher.getInstance(TRANSFORM) + cipher.init( + Cipher.DECRYPT_MODE, + key, + GCMParameterSpec(TAG_BITS, bytes.copyOfRange(0, IV_LEN)) + ) + String(cipher.doFinal(bytes.copyOfRange(IV_LEN, bytes.size)), Charsets.UTF_8) + } + } catch (e: Exception) { + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/Models.kt b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/Models.kt new file mode 100644 index 00000000..e92f6cb6 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/Models.kt @@ -0,0 +1,101 @@ +package com.ahu.ahutong.data.xuexiaotong + +import kotlinx.serialization.Serializable + +@Serializable +data class Course( + val courseId: String = "", + val clazzId: String = "", + val cpi: String = "", + val name: String = "", + val href: String = "" +) + +data class CourseKeys( + val enc: String = "", + val workEnc: String = "" +) + +@Serializable +data class Work( + val workId: String = "", + val answerId: String = "", + val courseId: String = "", + val courseName: String = "", + val title: String = "", + val status: String = "", + val detailUrl: String = "", + val startTs: Long? = null, + val endTs: Long? = null, + val rawStart: String = "", + val rawEnd: String = "", + val colorBg: String = "#FFE0CC", + val colorText: String = "#B3451E" +) { + val isDone: Boolean + get() = when (status) { + "已完成", "待批阅", "已批改", "未批改", "待批改", "已提交" -> true + else -> false + } + val remainMs: Long get() = (endTs ?: 0L) - System.currentTimeMillis() +} + +@Serializable +data class CourseProgress( + val courseId: String = "", + val clazzId: String = "", + val cpi: String = "", + val name: String = "", + val doneCount: Int = 0, + val totalCount: Int = 0, + val percent: Int = 0, + val updatedAt: Long = 0L +) + +@Serializable +data class CustomEvent( + val id: String = "", + val title: String = "", + val startDate: String = "", + val startTime: String = "", + val endDate: String = "", + val endTime: String = "", + val startTs: Long = 0L, + val endTs: Long = 0L, + val done: Boolean = false, + val colorBg: String = "#E8EAF6", + val colorText: String = "#3F51B5" +) + +@Serializable +data class RemindSetting( + val enabled: Boolean = false, + val leadMinutes: Int = 60, + val onlyTodo: Boolean = true +) + +data class ColorPair(val bg: String, val text: String) + +object CourseColors { + val PALETTE = listOf( + ColorPair("#FFE8E0", "#C05621"), + ColorPair("#FFF3D6", "#B7791F"), + ColorPair("#FFF8DC", "#9C7B0A"), + ColorPair("#DDF5E4", "#276749"), + ColorPair("#E0F4F9", "#1E6FA3"), + ColorPair("#E9E4FD", "#5B45A8"), + ColorPair("#FDE4F0", "#8B4B93"), + ColorPair("#DFF7F2", "#157A6E"), + ColorPair("#F6EEE2", "#7A5C40"), + ColorPair("#FFE2E5", "#B23A5E"), + ColorPair("#E8F1F8", "#35597A"), + ColorPair("#F2E8DD", "#8A6B4A"), + ColorPair("#E6F4E6", "#2E6B3A"), + ColorPair("#FDE8F2", "#A3486B") + ) + + fun byCourseId(courseId: String): ColorPair { + val hash = courseId.hashCode().let { if (it == Int.MIN_VALUE) 0 else kotlin.math.abs(it) } + return PALETTE[hash % PALETTE.size] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/PersistentCookieJar.kt b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/PersistentCookieJar.kt new file mode 100644 index 00000000..7351626e --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/PersistentCookieJar.kt @@ -0,0 +1,114 @@ +package com.ahu.ahutong.data.xuexiaotong + +import android.content.Context +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import org.json.JSONObject + +class PersistentCookieJar(private val context: Context) : CookieJar { + + private val cache = mutableMapOf>() + + init { + restore() + } + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + val list = cache.getOrPut(url.host) { mutableListOf() } + cookies.forEach { c -> + list.removeAll { it.name == c.name } + list.add(c) + } + persist() + } + + override fun loadForRequest(url: HttpUrl): List { + val now = System.currentTimeMillis() + val map = linkedMapOf() + cache.forEach { (_, list) -> + list.filter { it.expiresAt > now }.forEach { c -> + map[c.name] = c + } + } + return map.values.toList() + } + + fun cookieString(): String { + val now = System.currentTimeMillis() + val map = linkedMapOf() + cache.forEach { (_, list) -> + list.filter { it.expiresAt > now }.forEach { c -> + map[c.name] = "${c.name}=${c.value}" + } + } + return map.values.joinToString("; ") + } + + fun clear() { + cache.clear() + context.getSharedPreferences("ahutong_cx_cookies", Context.MODE_PRIVATE) + .edit().clear().apply() + } + + fun restoreFromString(cookieStr: String) { + if (cookieStr.isEmpty()) return + clear() + val list = cookieStr.split("; ").mapNotNull { kv -> + val eq = kv.indexOf('=') + if (eq <= 0) return@mapNotNull null + try { + Cookie.Builder() + .domain(".chaoxing.com") + .name(kv.substring(0, eq)) + .value(kv.substring(eq + 1)) + .expiresAt(Long.MAX_VALUE) + .build() + } catch (e: Exception) { null } + } + if (list.isNotEmpty()) { + cache[".chaoxing.com"] = list.toMutableList() + persist() + } + } + + private fun persist() { + try { + val all = JSONObject() + cache.forEach { (host, list) -> + all.put(host, list.joinToString("; ") { "${it.name}|${it.domain}=${it.value}" }) + } + context.getSharedPreferences("ahutong_cx_cookies", Context.MODE_PRIVATE) + .edit().putString("cookies", all.toString()).apply() + } catch (e: Exception) { } + } + + private fun restore() { + try { + val raw = context.getSharedPreferences("ahutong_cx_cookies", Context.MODE_PRIVATE) + .getString("cookies", null) ?: return + val all = JSONObject(raw) + all.keys().forEach { host -> + val str = all.getString(host) + val list = str.split("; ").mapNotNull { kv -> + val pipeIdx = kv.indexOf("|") + val eqIdx = kv.indexOf("=") + if (pipeIdx > 0 && eqIdx > pipeIdx) { + val name = kv.substring(0, pipeIdx) + val domain = kv.substring(pipeIdx + 1, eqIdx) + val value = kv.substring(eqIdx + 1) + try { + Cookie.Builder() + .domain(domain) + .name(name) + .value(value) + .expiresAt(Long.MAX_VALUE) + .build() + } catch (e: Exception) { null } + } else null + } + if (list.isNotEmpty()) cache[host] = list.toMutableList() + } + } catch (e: Exception) { } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/Store.kt b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/Store.kt new file mode 100644 index 00000000..0f573ae7 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/Store.kt @@ -0,0 +1,163 @@ +package com.ahu.ahutong.data.xuexiaotong + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +object Store { + private const val PREFS = "ahutong_cx_prefs" + private lateinit var sp: SharedPreferences + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + fun init(context: Context) { + sp = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + } + + private fun getString(key: String, def: String = ""): String = + sp.getString(key, def) ?: def + + private fun putString(key: String, value: String) { + sp.edit().putString(key, value).apply() + } + + private fun getBool(key: String, def: Boolean): Boolean = + sp.getBoolean(key, def) + + private fun putBool(key: String, value: Boolean) { + sp.edit().putBoolean(key, value).apply() + } + + private fun getLong(key: String, def: Long): Long = + sp.getLong(key, def) + + private fun putLong(key: String, value: Long) { + sp.edit().putLong(key, value).apply() + } + + private fun remove(key: String) { + sp.edit().remove(key).apply() + } + + fun saveCookie(cookie: String) = putString("cx_cookie", cookie) + fun getCookie(): String = getString("cx_cookie") + fun hasCookie(): Boolean = getCookie().length > 20 + fun clearCookie() = remove("cx_cookie") + + fun saveCredential(phone: String, pwd: String) { + putString("cx_cred_phone", CredentialCrypto.encrypt(phone)) + putString("cx_cred_pwd", CredentialCrypto.encrypt(pwd)) + } + fun getCredential(): Pair? { + val p = getString("cx_cred_phone") + val w = getString("cx_cred_pwd") + if (p.isEmpty() || w.isEmpty()) return null + val dp = CredentialCrypto.decrypt(p) ?: return null + val dw = CredentialCrypto.decrypt(w) ?: return null + return dp to dw + } + fun hasCredential(): Boolean = getString("cx_cred_phone").isNotEmpty() + fun clearCredential() { + remove("cx_cred_phone") + remove("cx_cred_pwd") + } + + fun getKeepLogin(): Boolean = sp.getBoolean("cx_keep_login", true) + fun saveKeepLogin(v: Boolean) = putBool("cx_keep_login", v) + + fun saveCourses(list: List) { + putString("cx_courses", json.encodeToString(list)) + } + fun getCourses(): List { + val raw = getString("cx_courses") + if (raw.isEmpty()) return emptyList() + return try { json.decodeFromString>(raw) } catch (e: Exception) { emptyList() } + } + + fun saveWorks(list: List) { + putString("cx_works", json.encodeToString(list)) + } + fun getWorks(): List { + val raw = getString("cx_works") + if (raw.isEmpty()) return emptyList() + return try { json.decodeFromString>(raw) } catch (e: Exception) { emptyList() } + } + + fun saveLastSync(ts: Long) = putLong("cx_last_sync", ts) + fun getLastSync(): Long = getLong("cx_last_sync", 0) + + fun saveCourseColor(courseId: String, bg: String, text: String) { + putString("cx_color_$courseId", "$bg|$text") + } + fun getCourseColorCached(courseId: String): ColorPair? { + val raw = getString("cx_color_$courseId") + if (raw.isEmpty()) return null + val parts = raw.split("|") + return if (parts.size == 2) ColorPair(parts[0], parts[1]) else null + } + + fun getDarkMode(): Boolean = sp.getBoolean("cx_dark_mode", false) + fun saveDarkMode(dark: Boolean) = putBool("cx_dark_mode", dark) + fun toggleDark(): Boolean { + val next = !getDarkMode() + saveDarkMode(next) + return next + } + + fun saveRemindSetting(s: RemindSetting) { + putString("cx_remind_setting", json.encodeToString(s)) + } + fun getRemindSetting(): RemindSetting { + val raw = getString("cx_remind_setting") + if (raw.isEmpty()) return RemindSetting() + return try { json.decodeFromString(raw) } catch (e: Exception) { RemindSetting() } + } + + fun saveRemindedMap(map: Map) { + putString("cx_reminded", json.encodeToString(map)) + } + fun getRemindedMap(): Map { + val raw = getString("cx_reminded") + if (raw.isEmpty()) return emptyMap() + return try { json.decodeFromString>(raw) } catch (e: Exception) { emptyMap() } + } + + fun saveCustomEvents(list: List) { + putString("cx_custom_events", json.encodeToString(list)) + } + fun getCustomEvents(): List { + val raw = getString("cx_custom_events") + if (raw.isEmpty()) return emptyList() + return try { json.decodeFromString>(raw) } catch (e: Exception) { emptyList() } + } + + fun getShowEmptyCourses(): Boolean = sp.getBoolean("cx_show_empty_courses", false) + fun saveShowEmptyCourses(v: Boolean) = putBool("cx_show_empty_courses", v) + + fun getShowDone(): Boolean = sp.getBoolean("cx_show_done", true) + fun saveShowDone(v: Boolean) = putBool("cx_show_done", v) + + fun getDoneGray(): Boolean = sp.getBoolean("cx_done_gray", true) + fun saveDoneGray(v: Boolean) = putBool("cx_done_gray", v) + + fun saveCourseProgress(list: List) { + putString("cx_course_progress", json.encodeToString(list)) + } + fun getCourseProgress(): List { + val raw = getString("cx_course_progress") + if (raw.isEmpty()) return emptyList() + return try { json.decodeFromString>(raw) } catch (e: Exception) { emptyList() } + } + + fun clearLoginData() { + remove("cx_cookie") + remove("cx_courses") + remove("cx_works") + remove("cx_last_sync") + remove("cx_reminded") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/personalization/telemetry/TelemetryV3Models.kt b/app/src/main/java/com/ahu/ahutong/personalization/telemetry/TelemetryV3Models.kt index 2d9529dd..41ad4774 100644 --- a/app/src/main/java/com/ahu/ahutong/personalization/telemetry/TelemetryV3Models.kt +++ b/app/src/main/java/com/ahu/ahutong/personalization/telemetry/TelemetryV3Models.kt @@ -391,8 +391,8 @@ object TelemetryV3PayloadValidator { internal const val TELEMETRY_V3_MIN_TASK_SAMPLES = 64 internal const val TELEMETRY_V3_METRIC_SCHEMA_VERSION = 2 internal const val TELEMETRY_V3_STORAGE_SCHEMA_VERSION = 1 -// Raise only after the fixed openahu.org endpoint accepts schema v3 credentials and batches. -internal const val TELEMETRY_SERVER_SCHEMA_VERSION = 2 +// The production openahu.org endpoint accepts schema v3 credentials and batches. +internal const val TELEMETRY_SERVER_SCHEMA_VERSION = 3 internal const val CALIBRATION_BIN_COUNT = 10 internal fun emptyCalibrationBins(): List = List(CALIBRATION_BIN_COUNT) { index -> diff --git a/app/src/main/java/com/ahu/ahutong/reminder/AlarmReceiver.kt b/app/src/main/java/com/ahu/ahutong/reminder/AlarmReceiver.kt new file mode 100644 index 00000000..88a4e8c4 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/reminder/AlarmReceiver.kt @@ -0,0 +1,21 @@ +package com.ahu.ahutong.reminder + +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +class AlarmReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val title = intent.getStringExtra(ReminderScheduler.EXTRA_TITLE) ?: "学习通日历" + val content = intent.getStringExtra(ReminderScheduler.EXTRA_CONTENT) ?: "提醒" + + ReminderScheduler.ensureChannel(context) + val manager = context.getSystemService(NotificationManager::class.java) + manager.notify( + System.currentTimeMillis().hashCode(), + ReminderScheduler.buildReminderNotification(context, title, content) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/reminder/BootReceiver.kt b/app/src/main/java/com/ahu/ahutong/reminder/BootReceiver.kt new file mode 100644 index 00000000..e91f8d67 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/reminder/BootReceiver.kt @@ -0,0 +1,19 @@ +package com.ahu.ahutong.reminder + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Intent.ACTION_BOOT_COMPLETED, + Intent.ACTION_LOCKED_BOOT_COMPLETED, + Intent.ACTION_MY_PACKAGE_REPLACED, + Intent.ACTION_TIME_CHANGED, + Intent.ACTION_TIMEZONE_CHANGED -> { + ReminderScheduler.scheduleAll(context) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/reminder/ReminderScheduler.kt b/app/src/main/java/com/ahu/ahutong/reminder/ReminderScheduler.kt new file mode 100644 index 00000000..5753dff2 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/reminder/ReminderScheduler.kt @@ -0,0 +1,194 @@ +package com.ahu.ahutong.reminder + +import android.app.AlarmManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import com.ahu.ahutong.data.xuexiaotong.Store +import java.util.Calendar + +object ReminderScheduler { + + const val CHANNEL_ID = "ahutong_cx_reminder" + const val EXTRA_TITLE = "extra_title" + const val EXTRA_CONTENT = "extra_content" + + fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "学习通作业提醒", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "作业截止与自定义日程提醒" + enableVibration(true) + } + context.getSystemService(NotificationManager::class.java) + .createNotificationChannel(channel) + } + } + + private fun allReminderKeys(): List { + val now = System.currentTimeMillis() + val keys = mutableListOf() + Store.getWorks().forEach { w -> + val endTs = w.endTs ?: return@forEach + if (endTs > now) keys.add("${w.workId}|$endTs") + } + Store.getCustomEvents().forEach { ev -> + val startTs = ev.startTs + if (startTs > 0 && startTs > now) keys.add("event_${ev.id}|$startTs") + } + return keys + } + + fun scheduleAll(context: Context) { + ensureChannel(context) + val setting = Store.getRemindSetting() + if (!setting.enabled) return + + val now = System.currentTimeMillis() + val works = Store.getWorks() + val events = Store.getCustomEvents() + val reminded = Store.getRemindedMap().toMutableMap() + + works.forEach { w -> + val endTs = w.endTs ?: return@forEach + if (endTs <= now) return@forEach + if (w.isDone && !setting.onlyTodo) return@forEach + + val remindAt = endTs - setting.leadMinutes * 60000L + if (remindAt <= now) return@forEach + + val key = "${w.workId}|$endTs" + if (reminded.containsKey(key)) return@forEach + + val timeStr = formatTime(endTs) + val ok = scheduleNotification( + context, key, remindAt, w.courseName.ifEmpty { "作业提醒" }, + "${w.title} 将于 $timeStr 截止" + ) + if (ok) reminded[key] = 1 + } + + events.forEach { ev -> + if (ev.done) return@forEach + val startTs = ev.startTs + if (startTs <= 0 || startTs <= now) return@forEach + + val remindAt = startTs - setting.leadMinutes * 60000L + if (remindAt <= now) return@forEach + + val key = "event_${ev.id}|$startTs" + if (reminded.containsKey(key)) return@forEach + + val ok = scheduleNotification( + context, key, remindAt, "日程提醒", + "${ev.title} 将于 ${ev.startDate.substring(5)} ${ev.startTime} 开始" + ) + if (ok) reminded[key] = 1 + } + + Store.saveRemindedMap(reminded) + } + + fun cancelAll(context: Context) { + val am = context.getSystemService(AlarmManager::class.java) + allReminderKeys().forEach { key -> + val pi = PendingIntent.getBroadcast( + context, key.hashCode(), + Intent(context, AlarmReceiver::class.java), + PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE + ) + pi?.let { + am.cancel(it) + it.cancel() + } + } + } + + fun rescheduleAll(context: Context) { + cancelAll(context) + Store.saveRemindedMap(emptyMap()) + scheduleAll(context) + } + + private fun formatTime(ts: Long): String { + val c = Calendar.getInstance().apply { timeInMillis = ts } + val month = c.get(Calendar.MONTH) + 1 + val day = c.get(Calendar.DAY_OF_MONTH) + val hh = c.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0') + val mm = c.get(Calendar.MINUTE).toString().padStart(2, '0') + return "$month-$day $hh:$mm" + } + + private fun scheduleNotification(context: Context, key: String, fireTs: Long, title: String, content: String): Boolean { + return try { + val alarmManager = context.getSystemService(AlarmManager::class.java) + val intent = Intent(context, AlarmReceiver::class.java).apply { + putExtra(EXTRA_TITLE, title) + putExtra(EXTRA_CONTENT, content) + } + val pending = PendingIntent.getBroadcast( + context, + key.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { + alarmManager.set(AlarmManager.RTC_WAKEUP, fireTs, pending) + } else { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, fireTs, pending) + } + } catch (e: SecurityException) { + alarmManager.set(AlarmManager.RTC_WAKEUP, fireTs, pending) + } + true + } catch (e: Exception) { false } + } + + fun canScheduleExact(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms() + } + return true + } + + fun sendTest(context: Context): Boolean { + return try { + ensureChannel(context) + val manager = context.getSystemService(NotificationManager::class.java) + manager.notify( + System.currentTimeMillis().hashCode(), + buildReminderNotification(context, "学习通日历", "这是一条测试通知") + ) + true + } catch (e: Exception) { false } + } + + fun buildReminderNotification(context: Context, title: String, content: String): android.app.Notification { + ensureChannel(context) + val launch = PendingIntent.getActivity( + context, 0, + context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } ?: Intent(), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(content) + .setStyle(NotificationCompat.BigTextStyle().bigText(content)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setContentIntent(launch) + .build() + } +} \ 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..8e0d8f9c 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 @@ -63,6 +63,7 @@ import com.ahu.ahutong.ui.screen.main.SchoolCalendar import com.ahu.ahutong.ui.screen.main.Tools import com.ahu.ahutong.ui.screen.main.RepositorySettings import com.ahu.ahutong.ui.screen.main.Weather +import com.ahu.ahutong.ui.screen.xuexiaotong.XuexiaotongScreen import com.ahu.ahutong.ui.screen.settings.Contributors import com.ahu.ahutong.ui.screen.settings.Debug import com.ahu.ahutong.ui.screen.settings.License @@ -330,6 +331,15 @@ fun Main( NetworkRecharge() } + animatedComposable("xuexiaotong") { + val context = LocalContext.current + val api = remember { com.ahu.ahutong.data.xuexiaotong.ChaoxingApi(context) } + XuexiaotongScreen( + api = api, + onBack = { navController.popBackStack() } + ) + } + animatedComposable("debug") { Debug( scheduleViewModel = scheduleViewModel, diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeWidgetRegistry.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeWidgetRegistry.kt index 80444269..7a6c6a69 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeWidgetRegistry.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeWidgetRegistry.kt @@ -91,6 +91,13 @@ object HomeWidgetRegistry { route = "repository", iconId = R.drawable.ic_repository, tint = Color(0xFF8D6E63) + ), + HomeWidgetSpec( + id = "xuexiaotong", + title = "学习通日历", + route = "xuexiaotong", + iconId = R.drawable.ic_xuexiaotong, + tint = Color(0xFF7C4DFF) ) ) diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/AddEventDialog.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/AddEventDialog.kt new file mode 100644 index 00000000..9ca995ab --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/AddEventDialog.kt @@ -0,0 +1,377 @@ +package com.ahu.ahutong.ui.screen.xuexiaotong + +import android.app.DatePickerDialog +import android.app.TimePickerDialog +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +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.compose.ui.window.Dialog +import com.ahu.ahutong.data.xuexiaotong.CustomEvent +import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.kyant.monet.n1 +import com.kyant.monet.withNight +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale + +data class EventFormState( + val title: String = "", + val startDate: String = todayStr(), + val startTime: String = "09:00", + val endDate: String = todayStr(), + val endTime: String = "18:00", + val colorBg: String = "#FFE8E0", + val colorText: String = "#C05621" +) + +private val EVENT_COLORS = listOf( + "#FFE8E0" to "#C05621", + "#FFF3D6" to "#B7791F", + "#DDF5E4" to "#276749", + "#E0F4F9" to "#1E6FA3", + "#E9E4FD" to "#5B45A8", + "#FDE4F0" to "#8B4B93", + "#DFF7F2" to "#157A6E", + "#FFE2E5" to "#B23A5E" +) + +private fun todayStr(): String { + val c = Calendar.getInstance() + val pad = { n: Int -> n.toString().padStart(2, '0') } + return "${c.get(Calendar.YEAR)}-${pad(c.get(Calendar.MONTH) + 1)}-${pad(c.get(Calendar.DAY_OF_MONTH))}" +} + +private fun parseDateMs(date: String, time: String): Long { + return try { + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) + sdf.parse("$date $time")?.time ?: System.currentTimeMillis() + } catch (e: Exception) { + System.currentTimeMillis() + } +} + +@Composable +fun AddEventDialog( + onDismiss: () -> Unit, + onSave: (CustomEvent) -> Unit +) { + var form by remember { mutableStateOf(EventFormState()) } + var errText by remember { mutableStateOf("") } + val context = LocalContext.current + + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .clip(SmoothRoundedCornerShape(32.dp)) + .background(96.n1 withNight 10.n1) + ) { + Column( + modifier = Modifier + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + "新建日程", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold + ) + + // 任务名 + OutlinedTextField( + value = form.title, + onValueChange = { form = form.copy(title = it) }, + label = { Text("任务名") }, + placeholder = { Text("给日程起个名字") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + colors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + ) + ) + } + + // 分割线 + Box( + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .background(80.n1 withNight 30.n1) + ) + + Column( + modifier = Modifier + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + // 开始时间 + NativeDateTimeRow( + label = "开始", + date = form.startDate, + time = form.startTime, + onDatePick = { date -> + val parts = date.split("-") + val y = parts[0].toInt() + val m = parts[1].toInt() - 1 + val d = parts[2].toInt() + DatePickerDialog(context, { _, yy, mm, dd -> + form = form.copy(startDate = "$yy-${(mm + 1).toString().padStart(2, '0')}-${dd.toString().padStart(2, '0')}") + }, y, m, d).show() + }, + onTimePick = { time -> + val parts = time.split(":") + TimePickerDialog(context, { _, h, m -> + form = form.copy(startTime = "$h:${m.toString().padStart(2, '0')}") + }, parts[0].toInt(), parts[1].toInt(), true).show() + } + ) + + // 结束时间 + NativeDateTimeRow( + label = "结束", + date = form.endDate, + time = form.endTime, + onDatePick = { date -> + val parts = date.split("-") + val y = parts[0].toInt() + val m = parts[1].toInt() - 1 + val d = parts[2].toInt() + DatePickerDialog(context, { _, yy, mm, dd -> + form = form.copy(endDate = "$yy-${(mm + 1).toString().padStart(2, '0')}-${dd.toString().padStart(2, '0')}") + }, y, m, d).show() + }, + onTimePick = { time -> + val parts = time.split(":") + TimePickerDialog(context, { _, h, m -> + form = form.copy(endTime = "$h:${m.toString().padStart(2, '0')}") + }, parts[0].toInt(), parts[1].toInt(), true).show() + } + ) + + // 颜色选择 + Text( + "颜色", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + EVENT_COLORS.forEach { (bg, text) -> + Box( + modifier = Modifier + .size(32.dp) + .clickable { form = form.copy(colorBg = bg, colorText = text) } + .background(Color(android.graphics.Color.parseColor(bg)), CircleShape) + .then( + if (form.colorBg == bg) Modifier + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), + CircleShape + ) + .padding(3.dp) + .background(Color(android.graphics.Color.parseColor(bg)), CircleShape) + else Modifier + ), + contentAlignment = Alignment.Center + ) { + if (form.colorBg == bg) { + Box( + modifier = Modifier + .size(12.dp) + .background(Color.White, CircleShape) + ) + } + } + } + } + + if (errText.isNotEmpty()) { + Text( + errText, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.error + ) + } + } + + // 分割线 + Box( + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .background(80.n1 withNight 30.n1) + ) + + // 按钮 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .weight(1f) + .height(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)) + .clickable { onDismiss() }, + contentAlignment = Alignment.Center + ) { + Text( + "取消", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium + ) + } + Box( + modifier = Modifier + .weight(1f) + .height(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)) + .clickable { + val title = form.title.trim() + if (title.isEmpty()) { + errText = "请输入任务名" + return@clickable + } + val startTs = parseDateMs(form.startDate, form.startTime) + val endTs = parseDateMs(form.endDate, form.endTime) + if (endTs < startTs) { + errText = "结束时间需晚于开始时间" + return@clickable + } + val ev = CustomEvent( + id = "event_${System.currentTimeMillis()}_${(Math.random() * 1000).toInt()}", + title = title, + startDate = form.startDate, + startTime = form.startTime, + endDate = form.endDate, + endTime = form.endTime, + startTs = startTs, + endTs = endTs, + done = false, + colorBg = form.colorBg, + colorText = form.colorText + ) + onSave(ev) + }, + contentAlignment = Alignment.Center + ) { + Text( + "保存", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium + ) + } + } + } + } +} + +@Composable +private fun NativeDateTimeRow( + label: String, + date: String, + time: String, + onDatePick: (String) -> Unit, + onTimePick: (String) -> Unit +) { + Column(Modifier.fillMaxWidth()) { + Text( + label, + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PickerField( + text = date, + modifier = Modifier.weight(1f), + onClick = { onDatePick(date) } + ) + PickerField( + text = time, + modifier = Modifier.weight(1f), + onClick = { onTimePick(time) } + ) + } + } +} + +@Composable +private fun PickerField( + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Box( + modifier = modifier + .height(42.dp) + .clickable(onClick = onClick) + .background( + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), + RoundedCornerShape(12.dp) + ), + contentAlignment = Alignment.Center + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + Spacer(Modifier.width(6.dp)) + Text( + "▾", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/CalendarModel.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/CalendarModel.kt new file mode 100644 index 00000000..dc09b70a --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/CalendarModel.kt @@ -0,0 +1,213 @@ +package com.ahu.ahutong.ui.screen.xuexiaotong + +import com.ahu.ahutong.data.xuexiaotong.CustomEvent +import com.ahu.ahutong.data.xuexiaotong.Store +import com.ahu.ahutong.data.xuexiaotong.Work +import java.util.Calendar + +data class CalendarCell( + val day: Int, + val outside: Boolean, + val isToday: Boolean, + val ts: Long +) + +data class CalendarBlock( + val work: Work, + val colStart: Int, + val colEnd: Int, + val continueNext: Boolean, + val lane: Int = 0 +) + +data class CalendarRow( + val cells: List, + val blocks: List, + val laneCount: Int, + val layerHeight: Int +) + +data class MonthModel( + val rows: List, + val noWorks: Boolean +) + +object CalendarModel { + + private const val DAY_MS = 86400000L + const val BLOCK_H = 16 + private const val BLOCK_GAP = 3 + const val BLOCK_STEP = BLOCK_H + BLOCK_GAP + const val LAYER_PAD = 4 + + fun buildMonth( + year: Int, + month: Int, + works: List, + customEvents: List, + showDone: Boolean = true + ): MonthModel { + val cal = Calendar.getInstance() + val now = Calendar.getInstance() + + cal.set(year, month - 1, 1) + cal.set(Calendar.HOUR_OF_DAY, 0); cal.clear(Calendar.MINUTE); cal.clear(Calendar.SECOND); cal.clear(Calendar.MILLISECOND) + val startWeekday = cal.get(Calendar.DAY_OF_WEEK) - 1 + val daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH) + + val cells = mutableListOf() + val prevCal = Calendar.getInstance().apply { + set(year, month - 2, 1) + set(Calendar.HOUR_OF_DAY, 0); clear(Calendar.MINUTE); clear(Calendar.SECOND); clear(Calendar.MILLISECOND) + } + val prevDays = prevCal.getActualMaximum(Calendar.DAY_OF_MONTH) + for (i in startWeekday - 1 downTo 0) { + val d = prevDays - i + val ts = prevCal.apply { set(Calendar.DAY_OF_MONTH, d) }.timeInMillis + cells.add(CalendarCell(d, true, false, ts)) + } + for (d in 1..daysInMonth) { + val ts = cal.apply { set(Calendar.DAY_OF_MONTH, d) }.timeInMillis + val isToday = now.get(Calendar.YEAR) == year && (now.get(Calendar.MONTH) + 1) == month && now.get(Calendar.DAY_OF_MONTH) == d + cells.add(CalendarCell(d, false, isToday, ts)) + } + var fill = 42 - cells.size + val nextCal = Calendar.getInstance().apply { + set(year, month, 1) + set(Calendar.HOUR_OF_DAY, 0); clear(Calendar.MINUTE); clear(Calendar.SECOND); clear(Calendar.MILLISECOND) + } + var d = 1 + while (fill > 0) { + val ts = nextCal.apply { set(Calendar.DAY_OF_MONTH, d) }.timeInMillis + cells.add(CalendarCell(d, true, false, ts)) + d++; fill-- + } + + val gridStart = cells.first().ts + val gridEnd = cells.last().ts + + val filteredWorks = if (showDone) works else works.filter { !it.isDone } + + val rowBlocks = Array(6) { mutableListOf() } + + filteredWorks.forEach { w -> + val ws = w.startTs ?: return@forEach + val we = w.endTs ?: return@forEach + val s = maxOf(startOfDay(ws), gridStart) + val e = minOf(startOfDay(we), gridEnd) + if (s > e) return@forEach + + val sIdx = ((s - gridStart) / DAY_MS).toInt() + val eIdx = ((e - gridStart) / DAY_MS).toInt() + + var idx = sIdx + while (idx <= eIdx) { + val row = idx / 7 + val rowStart = row * 7 + val segEnd = minOf(eIdx, rowStart + 6) + rowBlocks[row].add( + CalendarBlock( + work = w, + colStart = idx - rowStart, + colEnd = segEnd - rowStart, + continueNext = segEnd < eIdx + ) + ) + idx = segEnd + 1 + } + } + + customEvents.forEach { ev -> + val st = if (ev.startTs > 0) ev.startTs else (parseDateTs(ev.startDate) ?: return@forEach) + val et = if (ev.endTs > 0) ev.endTs else (parseDateTs(ev.endDate) ?: st) + val es = startOfDay(st).coerceAtLeast(gridStart) + val ee = startOfDay(et).coerceAtMost(gridEnd) + if (es > ee) return@forEach + + val item = Work( + workId = "event_${ev.id}", + courseId = "", + courseName = "自定义日程", + title = ev.title, + status = if (ev.done) "已完成" else "未完成", + startTs = st, + endTs = et, + colorBg = ev.colorBg, + colorText = ev.colorText + ) + + val sIdx = ((es - gridStart) / DAY_MS).toInt() + val eIdx = ((ee - gridStart) / DAY_MS).toInt() + var idx = sIdx + while (idx <= eIdx) { + val row = idx / 7 + val rowStart = row * 7 + val segEnd = minOf(eIdx, rowStart + 6) + rowBlocks[row].add( + CalendarBlock( + work = item, + colStart = idx - rowStart, + colEnd = segEnd - rowStart, + continueNext = segEnd < eIdx + ) + ) + idx = segEnd + 1 + } + } + + val rows = mutableListOf() + var hasAnyBlock = false + for (r in 0 until 6) { + val blocks = rowBlocks[r].sortedWith( + compareBy { it.colStart }.thenByDescending { it.colEnd } + ) + val laneEnds = mutableListOf() + val placed = blocks.map { b -> + var lane = 0 + var found = false + for (li in laneEnds.indices) { + if (b.colStart > laneEnds[li]) { + laneEnds[li] = b.colEnd + lane = li + found = true + break + } + } + if (!found) { + laneEnds.add(b.colEnd) + lane = laneEnds.size - 1 + } + b.copy(lane = lane) + } + if (placed.isNotEmpty()) hasAnyBlock = true + val laneCount = maxOf(1, laneEnds.size) + rows.add( + CalendarRow( + cells = cells.slice(r * 7 until (r + 1) * 7), + blocks = placed, + laneCount = laneCount, + layerHeight = if (placed.isNotEmpty()) laneCount * BLOCK_STEP + LAYER_PAD else LAYER_PAD + ) + ) + } + + return MonthModel(rows, !hasAnyBlock) + } + + private fun startOfDay(ts: Long): Long { + val c = Calendar.getInstance().apply { timeInMillis = ts } + c.set(Calendar.HOUR_OF_DAY, 0); c.clear(Calendar.MINUTE); c.clear(Calendar.SECOND); c.clear(Calendar.MILLISECOND) + return c.timeInMillis + } + + private fun parseDateTs(date: String): Long? { + val parts = date.split("-").mapNotNull { it.toIntOrNull() } + if (parts.size != 3) return null + return try { + val c = Calendar.getInstance() + c.clear() + c.set(parts[0], parts[1] - 1, parts[2]) + c.timeInMillis + } catch (e: Exception) { null } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/GlassComponents.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/GlassComponents.kt new file mode 100644 index 00000000..7b1598de --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/GlassComponents.kt @@ -0,0 +1,91 @@ +package com.ahu.ahutong.ui.screen.xuexiaotong + +import android.graphics.BlurMaskFilter +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +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.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.kyant.backdrop.Backdrop +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 + +fun Modifier.gaussianShadow( + blur: Dp = 22.dp, + alpha: Float = 0.18f, + offsetY: Dp = 5.dp +): Modifier = drawBehind { + drawIntoCanvas { canvas -> + val b = blur.toPx() + val oy = offsetY.toPx() + val corner = 24.dp.toPx() + val paint = android.graphics.Paint().apply { + color = android.graphics.Color.BLACK + this.alpha = (255 * alpha).toInt() + maskFilter = BlurMaskFilter(b, BlurMaskFilter.Blur.NORMAL) + } + canvas.nativeCanvas.drawRoundRect( + -b, -b + oy, size.width + b, size.height + b, + corner, corner, paint + ) + } +} + +fun Modifier.glassPill( + backdrop: Backdrop?, + blurRadius: Dp = 12.dp, + refractionHeight: Dp = 12.dp, + refractionAmount: Dp = 16.dp, + withLens: Boolean = true, + tintColor: Color = Color.White, + tintAlpha: Float = 0.08f +): Modifier { + if (backdrop == null) { + return this.background(tintColor.copy(alpha = 0.2f), RoundedCornerShape(50)) + } + return this.drawBackdrop( + backdrop = backdrop, + shape = { RoundedCornerShape(50) }, + effects = { + vibrancy() + blur(blurRadius.toPx()) + if (withLens) { + lens( + refractionHeight = refractionHeight.toPx(), + refractionAmount = refractionAmount.toPx(), + depthEffect = true + ) + } + }, + highlight = { Highlight.Default }, + onDrawSurface = { + drawRect(tintColor.copy(alpha = tintAlpha)) + } + ) +} + +@Composable +fun Modifier.noRippleClickable(enabled: Boolean = true, onClick: () -> Unit): Modifier { + val interactionSource = remember { MutableInteractionSource() } + return this + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled, + onClick = onClick + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/LiquidDockAnim.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/LiquidDockAnim.kt new file mode 100644 index 00000000..7c4d5c71 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/LiquidDockAnim.kt @@ -0,0 +1,197 @@ +package com.ahu.ahutong.ui.screen.xuexiaotong + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.spring +import androidx.compose.foundation.MutatorMutex +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.util.fastCoerceIn +import androidx.compose.ui.util.fastFirstOrNull +import androidx.compose.ui.unit.IntSize +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.math.abs + +class DampedDragAnimation( + private val animationScope: CoroutineScope, + val initialValue: Float, + val valueRange: ClosedRange, + val visibilityThreshold: Float, + val initialScale: Float, + val pressedScale: Float, + val onDragStarted: DampedDragAnimation.(position: Offset) -> Unit, + val onDragStopped: DampedDragAnimation.() -> Unit, + val onDrag: DampedDragAnimation.(size: IntSize, dragAmount: Offset) -> Unit, +) { + private val valueAnimationSpec = spring(1f, 1000f, visibilityThreshold) + private val velocityAnimationSpec = spring(0.5f, 300f, visibilityThreshold * 10f) + private val pressProgressAnimationSpec = spring(1f, 1000f, 0.001f) + private val scaleXAnimationSpec = spring(0.6f, 250f, 0.001f) + private val scaleYAnimationSpec = spring(0.7f, 250f, 0.001f) + + private val valueAnimation = Animatable(initialValue, visibilityThreshold) + private val velocityAnimation = Animatable(0f, 5f) + private val pressProgressAnimation = Animatable(0f, 0.001f) + private val scaleXAnimation = Animatable(initialScale, 0.001f) + private val scaleYAnimation = Animatable(initialScale, 0.001f) + + private val mutatorMutex = MutatorMutex() + private val velocityTracker = VelocityTracker() + + val value: Float get() = valueAnimation.value + val progress: Float get() = (value - valueRange.start) / (valueRange.endInclusive - valueRange.start) + val targetValue: Float get() = valueAnimation.targetValue + val pressProgress: Float get() = pressProgressAnimation.value + val scaleX: Float get() = scaleXAnimation.value + val scaleY: Float get() = scaleYAnimation.value + val velocity: Float get() = velocityAnimation.value + + val modifier: Modifier = Modifier.pointerInput(Unit) { + inspectDragGestures( + onDragStart = { down -> + onDragStarted(down.position) + press() + }, + onDragEnd = { + onDragStopped() + release() + }, + onDragCancel = { + onDragStopped() + release() + } + ) { change, dragAmount -> + onDrag(size, dragAmount) + } + } + + fun press() { + velocityTracker.resetTracking() + animationScope.launch { + launch { pressProgressAnimation.animateTo(1f, pressProgressAnimationSpec) } + launch { scaleXAnimation.animateTo(pressedScale, scaleXAnimationSpec) } + launch { scaleYAnimation.animateTo(pressedScale, scaleYAnimationSpec) } + } + } + + fun release() { + animationScope.launch { + awaitFrame() + if (value != targetValue) { + val threshold = (valueRange.endInclusive - valueRange.start) * 0.025f + snapshotFlow { valueAnimation.value } + .filter { abs(it - valueAnimation.targetValue) < threshold } + .first() + } + launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) } + launch { scaleXAnimation.animateTo(initialScale, scaleXAnimationSpec) } + launch { scaleYAnimation.animateTo(initialScale, scaleYAnimationSpec) } + } + } + + fun updateValue(value: Float) { + val targetValue = value.coerceIn(valueRange) + animationScope.launch { + launch { valueAnimation.animateTo(targetValue, valueAnimationSpec) { updateVelocity() } } + } + } + + fun animateToValue(value: Float) { + animationScope.launch { + mutatorMutex.mutate { + press() + val targetValue = value.coerceIn(valueRange) + launch { valueAnimation.animateTo(targetValue, valueAnimationSpec) } + if (velocity != 0f) { + launch { velocityAnimation.animateTo(0f, velocityAnimationSpec) } + } + release() + } + } + } + + private fun updateVelocity() { + velocityTracker.addPosition( + System.currentTimeMillis(), + Offset(value, 0f) + ) + val targetVelocity = velocityTracker.calculateVelocity().x / (valueRange.endInclusive - valueRange.start) + animationScope.launch { velocityAnimation.animateTo(targetVelocity, velocityAnimationSpec) } + } +} + +private suspend fun awaitFrame() { + withFrameNanos { } +} + +private suspend fun PointerInputScope.inspectDragGestures( + onDragStart: (down: PointerInputChange) -> Unit = {}, + onDragEnd: (change: PointerInputChange) -> Unit = {}, + onDragCancel: () -> Unit = {}, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit +) { + awaitEachGesture { + val initialDown = awaitFirstDown(false, PointerEventPass.Initial) + val down = awaitFirstDown(false) + onDragStart(down) + onDrag(initialDown, Offset.Zero) + val upEvent = drag( + pointerId = initialDown.id, + onDrag = { onDrag(it, it.positionChange()) } + ) + if (upEvent == null) { + onDragCancel() + } else { + onDragEnd(upEvent) + } + } +} + +private suspend inline fun AwaitPointerEventScope.drag( + pointerId: PointerId, + onDrag: (PointerInputChange) -> Unit +): PointerInputChange? { + val isPointerUp = currentEvent.changes.fastFirstOrNull { it.id == pointerId }?.pressed != true + if (isPointerUp) return null + var pointer = pointerId + while (true) { + val change = awaitDragOrUp(pointer) ?: return null + if (change.isConsumed) return null + if (change.changedToUpIgnoreConsumed()) return change + onDrag(change) + pointer = change.id + } +} + +private suspend inline fun AwaitPointerEventScope.awaitDragOrUp( + pointerId: PointerId +): PointerInputChange? { + var pointer = pointerId + while (true) { + val event = awaitPointerEvent() + val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null + if (dragEvent.changedToUpIgnoreConsumed()) { + val otherDown = event.changes.fastFirstOrNull { it.pressed } + if (otherDown == null) return dragEvent + else pointer = otherDown.id + } else { + val hasDragged = dragEvent.previousPosition != dragEvent.position + if (hasDragged) return dragEvent + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/RemindDialog.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/RemindDialog.kt new file mode 100644 index 00000000..5d62d67b --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/RemindDialog.kt @@ -0,0 +1,247 @@ +package com.ahu.ahutong.ui.screen.xuexiaotong + +import android.app.AlarmManager +import android.content.Intent +import android.os.Build +import android.provider.Settings +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +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.compose.ui.window.Dialog +import com.ahu.ahutong.data.xuexiaotong.RemindSetting +import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.kyant.monet.n1 +import com.kyant.monet.withNight + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RemindDialog( + setting: RemindSetting, + onSave: (RemindSetting) -> Unit, + onDismiss: () -> Unit, + onTest: () -> Unit +) { + var enabled by remember { mutableStateOf(setting.enabled) } + var lead by remember { mutableIntStateOf(setting.leadMinutes) } + var onlyTodo by remember { mutableStateOf(setting.onlyTodo) } + + val leadOptions = listOf(0 to "截止时", 60 to "提前1小时", 360 to "提前6小时", 720 to "提前12小时", 1440 to "提前1天") + val context = LocalContext.current + + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .clip(SmoothRoundedCornerShape(32.dp)) + .background(96.n1 withNight 10.n1) + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + "通知设置", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold + ) + + // 开启作业提醒 + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "开启作业提醒", + modifier = Modifier.weight(1f), + fontSize = 14.sp + ) + Switch( + checked = enabled, + onCheckedChange = { enabled = it }, + modifier = Modifier.scale(0.78f), + colors = SwitchDefaults.colors(checkedTrackColor = MaterialTheme.colorScheme.primary) + ) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .background(80.n1 withNight 30.n1) + ) + + if (enabled) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Text( + "提前提醒", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + leadOptions.forEach { (v, label) -> + Box( + modifier = Modifier + .clickable { lead = v } + .background( + if (lead == v) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), + RoundedCornerShape(10.dp) + ) + .padding(horizontal = 10.dp, vertical = 6.dp) + ) { + Text( + label, + fontSize = 11.sp, + color = if (lead == v) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + HorizontalDivider() + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "仅提醒未完成", + modifier = Modifier.weight(1f), + fontSize = 14.sp + ) + Switch( + checked = onlyTodo, + onCheckedChange = { onlyTodo = it }, + modifier = Modifier.scale(0.78f), + colors = SwitchDefaults.colors(checkedTrackColor = MaterialTheme.colorScheme.primary) + ) + } + + // 精确闹钟权限引导 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val am = context.getSystemService(AlarmManager::class.java) + if (!am.canScheduleExactAlarms()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)) + .clickable { + try { + context.startActivity( + Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM) + ) + } catch (_: Exception) { } + } + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "精确提醒未开启,提醒可能延迟。", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + Text( + "去授权", + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .background(80.n1 withNight 30.n1) + ) + + // 操作按钮 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .weight(1f) + .height(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)) + .clickable { onDismiss() }, + contentAlignment = Alignment.Center + ) { + Text( + "取消", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium + ) + } + Box( + modifier = Modifier + .weight(1f) + .height(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)) + .clickable { + onSave(RemindSetting(enabled, lead, onlyTodo)) + onDismiss() + }, + contentAlignment = Alignment.Center + ) { + Text( + "保存", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/WorkDetailDialog.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/WorkDetailDialog.kt new file mode 100644 index 00000000..d9892266 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/WorkDetailDialog.kt @@ -0,0 +1,228 @@ +package com.ahu.ahutong.ui.screen.xuexiaotong + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Event +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.ahu.ahutong.data.xuexiaotong.Work +import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.kyant.monet.n1 +import com.kyant.monet.withNight +import java.util.Calendar + +@Composable +fun WorkDetailDialog( + work: Work, + onDismiss: () -> Unit, + onToggleDone: (() -> Unit)? = null, + onDelete: (() -> Unit)? = null +) { + val isCustom = work.workId.startsWith("event_") + + Dialog(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .clip(SmoothRoundedCornerShape(32.dp)) + .background(96.n1 withNight 10.n1) + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // 标题 + Text( + text = work.title, + style = MaterialTheme.typography.headlineMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + // 副标题:课程名(自定义日程则显示类型) + Text( + text = if (isCustom) "自定义日程" else work.courseName, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + // 分割线 + Box( + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .background(80.n1 withNight 30.n1) + ) + + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // 状态 + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.CheckCircle, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = if (work.isDone) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.width(8.dp)) + Text( + text = if (work.isDone) "已完成" else "未完成", + fontSize = 14.sp, + color = if (work.isDone) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface + ) + } + + // 开始时间 + work.startTs?.let { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.Event, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "开始时间", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.weight(1f)) + Text( + text = formatFullTime(it), + fontSize = 14.sp + ) + } + } + + // 截止时间 + work.endTs?.let { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.Schedule, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "截止时间", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.weight(1f)) + Text( + text = formatFullTime(it), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + } + + // 自定义日程的操作按钮 + if (isCustom) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .background(80.n1 withNight 30.n1) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + // 标记完成 / 恢复未完成 + onToggleDone?.let { + Box( + modifier = Modifier + .weight(1f) + .height(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background( + if (work.isDone) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + else MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + ) + .clickable { onToggleDone() }, + contentAlignment = Alignment.Center + ) { + Text( + text = if (work.isDone) "恢复未完成" else "标记完成", + fontSize = 13.sp, + color = if (work.isDone) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium + ) + } + } + // 删除该日程 + onDelete?.let { + Box( + modifier = Modifier + .weight(1f) + .height(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.error.copy(alpha = 0.15f)) + .clickable { onDelete() }, + contentAlignment = Alignment.Center + ) { + Text( + text = "删除该日程", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Medium + ) + } + } + } + } + } + } +} + +private fun formatFullTime(ts: Long): String { + val c = Calendar.getInstance().apply { timeInMillis = ts } + val hh = c.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0') + val mm = c.get(Calendar.MINUTE).toString().padStart(2, '0') + return "${c.get(Calendar.MONTH) + 1}月${c.get(Calendar.DAY_OF_MONTH)}日 $hh:$mm" +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongLoginScreen.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongLoginScreen.kt new file mode 100644 index 00000000..8520f472 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongLoginScreen.kt @@ -0,0 +1,170 @@ +package com.ahu.ahutong.ui.screen.xuexiaotong + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.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.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ahu.ahutong.data.xuexiaotong.ChaoxingApi +import com.ahu.ahutong.data.xuexiaotong.Store +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun XuexiaotongLoginScreen( + api: ChaoxingApi, + onLoginSuccess: () -> Unit +) { + var phone by remember { mutableStateOf("") } + var pwd by remember { mutableStateOf("") } + var showPwd by remember { mutableStateOf(false) } + var loading by remember { mutableStateOf(false) } + var errText by remember { mutableStateOf("") } + var agreed by remember { mutableStateOf(false) } + var showPrivacy by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + Box( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(80.dp)) + Text( + text = "学习通日历", + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "记得交作业哦~", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(32.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + RoundedCornerShape(20.dp) + ) + .padding(20.dp) + ) { + OutlinedTextField( + value = phone, + onValueChange = { phone = it }, + label = { Text("账号") }, + placeholder = { Text("手机号 / 超星号 / 邮箱") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = pwd, + onValueChange = { pwd = it }, + label = { Text("密码") }, + placeholder = { Text("请输入密码") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = if (showPwd) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + androidx.compose.material3.TextButton(onClick = { showPwd = !showPwd }) { + Text(if (showPwd) "隐藏" else "显示", fontSize = 13.sp) + } + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp) + ) + + if (errText.isNotEmpty()) { + Spacer(modifier = Modifier.height(10.dp)) + Text(errText, color = MaterialTheme.colorScheme.error, fontSize = 13.sp) + } + + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = { + errText = "" + if (phone.isBlank()) { errText = "请输入账号"; return@Button } + if (pwd.isBlank()) { errText = "请输入密码"; return@Button } + loading = true + scope.launch { + try { + withContext(Dispatchers.IO) { + api.loginByPassword(phone, pwd) + Store.saveCredential(phone, pwd) + } + onLoginSuccess() + } catch (e: Exception) { + errText = e.message ?: "登录失败" + } finally { + loading = false + } + } + }, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(24.dp), + enabled = !loading + ) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.size(8.dp)) + Text("登录中...") + } else { + Text("登录", fontSize = 16.sp) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongScreen.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongScreen.kt new file mode 100644 index 00000000..36008593 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongScreen.kt @@ -0,0 +1,1067 @@ +package com.ahu.ahutong.ui.screen.xuexiaotong + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +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 +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +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.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ahu.ahutong.data.xuexiaotong.ChaoxingApi +import com.ahu.ahutong.data.xuexiaotong.CourseProgress +import com.ahu.ahutong.data.xuexiaotong.CustomEvent +import com.ahu.ahutong.data.xuexiaotong.Work +import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.kyant.capsule.ContinuousCapsule +import com.kyant.monet.n1 +import com.kyant.monet.withNight +import kotlin.math.roundToInt +import java.util.Calendar + +private enum class Tab { SCHEDULE, COURSE } + +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun XuexiaotongScreen( + api: ChaoxingApi, + onBack: () -> Unit +) { + val viewModel = androidx.lifecycle.viewmodel.compose.viewModel( + factory = XuexiaotongViewModel.Factory(api, androidx.compose.ui.platform.LocalContext.current) + ) + + val loggedIn by viewModel.loggedIn.collectAsState() + val works by viewModel.works.collectAsState() + val progress by viewModel.progress.collectAsState() + val syncing by viewModel.syncing.collectAsState() + val syncMsg by viewModel.syncProgress.collectAsState() + val courseSyncing by viewModel.courseSyncing.collectAsState() + val courseSyncMsg by viewModel.courseSyncProgress.collectAsState() + val lastSync by viewModel.lastSync.collectAsState() + val customEvents by viewModel.customEvents.collectAsState() + val showDone by viewModel.showDone.collectAsState() + val doneGray by viewModel.doneGray.collectAsState() + val showEmptyCourses by viewModel.showEmptyCourses.collectAsState() + val remindSetting by viewModel.remindSetting.collectAsState() + + if (!loggedIn) { + XuexiaotongLoginScreen( + api = api, + onLoginSuccess = { viewModel.onLoginSuccess() } + ) + return + } + + var tab by remember { mutableStateOf(Tab.SCHEDULE) } + var sideMenuOpen by remember { mutableStateOf(false) } + var selectedWork by remember { mutableStateOf(null) } + var showAddEvent by remember { mutableStateOf(false) } + var pendingDeleteEvent by remember { mutableStateOf(null) } + var showClearConfirm by remember { mutableStateOf(false) } + var showRemindDialog by remember { mutableStateOf(false) } + val today = Calendar.getInstance() + var year by remember { mutableIntStateOf(today.get(Calendar.YEAR)) } + var month by remember { mutableIntStateOf(today.get(Calendar.MONTH) + 1) } + + Box(Modifier.fillMaxSize().systemBarsPadding()) { + Column(Modifier.fillMaxSize()) { + // 标题栏:标题+副标题紧凑排列,右侧按钮 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, top = 12.dp, end = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column { + val mainTitle = if (tab == Tab.COURSE) "课程任务" else "${year}年${month}月" + Text( + mainTitle, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + // 副标题:同步时显示进度,否则显示上次同步时间 + val isSyncing = if (tab == Tab.COURSE) courseSyncing else syncing + val msg = if (tab == Tab.COURSE) courseSyncMsg else syncMsg + val subtitleText = if (isSyncing && msg.message.isNotEmpty()) { + msg.message + } else if (lastSync > 0) { + val c = Calendar.getInstance().apply { timeInMillis = lastSync } + val mm = (c.get(Calendar.MONTH) + 1).toString().padStart(2, '0') + val dd = c.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0') + val hh = c.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0') + val mi = c.get(Calendar.MINUTE).toString().padStart(2, '0') + "上次同步 $mm-$dd $hh:$mi" + } else "" + if (subtitleText.isNotEmpty()) { + Text( + subtitleText, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + // 操作按钮 + Row( + modifier = Modifier + .clip(ContinuousCapsule) + .background( + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f) + ) + ) { + if (tab == Tab.SCHEDULE) { + IconButton(onClick = { showAddEvent = true }) { + Icon( + Icons.Filled.Add, + contentDescription = "新建日程", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + IconButton(onClick = { sideMenuOpen = true }) { + Icon( + Icons.Filled.Menu, + contentDescription = "菜单", + tint = MaterialTheme.colorScheme.onSurface + ) + } + val isSyncingBtn = if (tab == Tab.COURSE) courseSyncing else syncing + if (isSyncingBtn) { + Box( + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center + ) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } + } else { + IconButton(onClick = { + if (tab == Tab.COURSE) viewModel.syncCourseProgress() + else viewModel.syncWorks() + }) { + Icon( + Icons.Filled.Refresh, + contentDescription = "同步", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + + // 标签页内容 + AnimatedContent( + targetState = tab, + transitionSpec = { + if (targetState.ordinal > initialState.ordinal) { + (slideInHorizontally { it } + fadeIn(tween(220))) togetherWith + (slideOutHorizontally { -it / 2 } + fadeOut(tween(180))) + } else { + (slideInHorizontally { -it } + fadeIn(tween(220))) togetherWith + (slideOutHorizontally { it / 2 } + fadeOut(tween(180))) + } + }, + label = "tabContent" + ) { t -> + Box(Modifier.weight(1f)) { + when (t) { + Tab.SCHEDULE -> { + ScheduleTab( + works = works, + customEvents = customEvents, + syncing = syncing, + syncMsg = syncMsg, + showDone = showDone, + doneGray = doneGray, + year = year, + month = month, + onChangeMonth = { delta -> + var m = month + delta + var y = year + if (m < 1) { m = 12; y-- } + if (m > 12) { m = 1; y++ } + year = y; month = m + }, + onWorkClick = { selectedWork = it } + ) + } + Tab.COURSE -> { + CourseTab( + progress = progress, + syncing = courseSyncing, + syncMsg = courseSyncMsg, + showEmptyCourses = showEmptyCourses + ) + } + } + } + } + } + + // 底部悬浮 Dock + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .navigationBarsPadding() + .padding(bottom = 8.dp), + contentAlignment = Alignment.Center + ) { + BottomDock(current = tab, onSelect = { tab = it }) + } + } + + // 任务详情弹窗 + selectedWork?.let { work -> + WorkDetailDialog( + work = work, + onDismiss = { selectedWork = null }, + onToggleDone = if (work.workId.startsWith("event_")) { + { + viewModel.toggleCustomEventDone(work.workId.removePrefix("event_")) + selectedWork = null + } + } else null, + onDelete = if (work.workId.startsWith("event_")) { + { + pendingDeleteEvent = work + selectedWork = null + } + } else null + ) + } + + // 新建日程弹窗 + if (showAddEvent) { + AddEventDialog( + onDismiss = { showAddEvent = false }, + onSave = { ev -> + viewModel.addCustomEvent(ev) + showAddEvent = false + } + ) + } + + // 删除自建日程确认弹窗 + pendingDeleteEvent?.let { w -> + androidx.compose.ui.window.Dialog(onDismissRequest = { pendingDeleteEvent = null }) { + Column( + modifier = Modifier + .clip(SmoothRoundedCornerShape(32.dp)) + .background(96.n1 withNight 10.n1) + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + "删除日程", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + "确定删除「${w.title}」吗?", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .background(80.n1 withNight 30.n1) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .weight(1f) + .height(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)) + .clickable { pendingDeleteEvent = null }, + contentAlignment = Alignment.Center + ) { + Text("取消", fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium) + } + Box( + modifier = Modifier + .weight(1f) + .height(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.error.copy(alpha = 0.15f)) + .clickable { + viewModel.deleteCustomEvent(w.workId.removePrefix("event_")) + pendingDeleteEvent = null + }, + contentAlignment = Alignment.Center + ) { + Text("删除", fontSize = 13.sp, color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.Medium) + } + } + } + } + } + + // 清空自建日程确认弹窗 + if (showClearConfirm) { + androidx.compose.ui.window.Dialog(onDismissRequest = { showClearConfirm = false }) { + Column( + modifier = Modifier + .clip(SmoothRoundedCornerShape(32.dp)) + .background(96.n1 withNight 10.n1) + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + "清空自建日程", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + "确定删除全部 ${customEvents.size} 条自建日程吗?此操作不可恢复", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .background(80.n1 withNight 30.n1) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .weight(1f) + .height(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)) + .clickable { showClearConfirm = false }, + contentAlignment = Alignment.Center + ) { + Text("取消", fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium) + } + Box( + modifier = Modifier + .weight(1f) + .height(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.error.copy(alpha = 0.15f)) + .clickable { + viewModel.clearCustomEvents() + showClearConfirm = false + }, + contentAlignment = Alignment.Center + ) { + Text("全部删除", fontSize = 13.sp, color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.Medium) + } + } + } + } + } + + // 通知设置弹窗 + if (showRemindDialog) { + RemindDialog( + setting = remindSetting, + onSave = { viewModel.saveRemind(it) }, + onDismiss = { showRemindDialog = false }, + onTest = { viewModel.sendTestNotification() } + ) + } + + // 底部抽屉菜单(参照天气页 ModalBottomSheet) + if (sideMenuOpen) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = { sideMenuOpen = false }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 0.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(24.dp) + ) { + Text( + "学习通日历", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Text( + "作业日历 · 课程进度", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(20.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)) + Spacer(Modifier.height(8.dp)) + BottomSheetSwitchItem("查看已完成作业", showDone) { viewModel.toggleShowDone() } + BottomSheetSwitchItem("查看无任务点课程", showEmptyCourses) { viewModel.toggleShowEmptyCourses() } + BottomSheetSwitchItem("已完成任务置灰", doneGray) { viewModel.toggleDoneGray() } + Spacer(Modifier.height(8.dp)) + // 通知设置 + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { sideMenuOpen = false; showRemindDialog = true } + .padding(vertical = 7.dp), + contentAlignment = Alignment.CenterStart + ) { + Text("通知设置", fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface) + } + Spacer(Modifier.height(16.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)) + Spacer(Modifier.height(12.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)) + .clickable { sideMenuOpen = false; showClearConfirm = true }, + contentAlignment = Alignment.Center + ) { + Text("清空自建日程", fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Medium) + } + Spacer(Modifier.height(8.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.error) + .clickable { viewModel.logout(); sideMenuOpen = false }, + contentAlignment = Alignment.Center + ) { + Text("退出学习通登录", fontSize = 13.sp, color = Color.White, fontWeight = FontWeight.Medium) + } + Spacer(Modifier.height(8.dp)) + } + } + } +} + +@Composable +private fun BottomSheetSwitchItem(label: String, checked: Boolean, onToggle: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(label, modifier = Modifier.weight(1f), fontSize = 14.sp) + Switch( + checked = checked, + onCheckedChange = { onToggle() }, + modifier = Modifier.scale(0.78f), + colors = SwitchDefaults.colors(checkedTrackColor = MaterialTheme.colorScheme.primary) + ) + } +} + +/* ==================== 底部悬浮 Dock ==================== */ + +@Composable +private fun BottomDock( + current: Tab, + modifier: Modifier = Modifier, + onSelect: (Tab) -> Unit +) { + val primary = MaterialTheme.colorScheme.primary + val surface = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.85f) + val dockWidthDp = 168.dp + var dockWidth by remember { mutableStateOf(0) } + val animationScope = rememberCoroutineScope() + val density = LocalDensity.current + var didDrag by remember { mutableStateOf(false) } + var startX by remember { mutableStateOf(0f) } + + val dampedDragAnimation = remember(animationScope) { + DampedDragAnimation( + animationScope = animationScope, + initialValue = if (current == Tab.COURSE) 1f else 0f, + valueRange = 0f..1f, + visibilityThreshold = 0.001f, + initialScale = 1f, + pressedScale = 1.25f, + onDragStarted = { position -> + didDrag = false + startX = position.x + }, + onDragStopped = { + val half = if (dockWidth > 0) dockWidth / 2f else with(density) { 84f.dp.toPx() } + val target = if (didDrag) { + if (targetValue >= 0.5f) 1f else 0f + } else { + if (startX >= half) 1f else 0f + } + animateToValue(target) + onSelect(if (target >= 0.5f) Tab.COURSE else Tab.SCHEDULE) + }, + onDrag = { _, dragAmount -> + if (dragAmount.x != 0f) didDrag = true + val tabWidth = if (dockWidth > 0) dockWidth / 2f else with(density) { 84f.dp.toPx() } + updateValue(targetValue + dragAmount.x / tabWidth) + } + ) + } + LaunchedEffect(current) { + dampedDragAnimation.animateToValue(if (current == Tab.COURSE) 1f else 0f) + } + + Box( + modifier = modifier + .width(dockWidthDp) + .height(56.dp) + .onSizeChanged { dockWidth = it.width } + ) { + // 底座 + Box( + modifier = Modifier + .fillMaxSize() + .background(surface, RoundedCornerShape(50)) + ) + // 选中滑块 + Box( + modifier = Modifier + .fillMaxWidth(0.5f) + .height(56.dp) + .offset { + IntOffset( + x = (dampedDragAnimation.progress * dockWidth / 2f).roundToInt(), + y = 0 + ) + } + .padding(4.dp) + .background(Color.White, RoundedCornerShape(50)), + contentAlignment = Alignment.Center + ) {} + // 文本层 + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .weight(1f) + .height(56.dp), + contentAlignment = Alignment.Center + ) { + Text( + "日程", + fontSize = 15.sp, + fontWeight = if (current == Tab.SCHEDULE) FontWeight.Bold else FontWeight.Normal, + color = if (current == Tab.SCHEDULE) primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Box( + modifier = Modifier + .weight(1f) + .height(56.dp), + contentAlignment = Alignment.Center + ) { + Text( + "课程", + fontSize = 15.sp, + fontWeight = if (current == Tab.COURSE) FontWeight.Bold else FontWeight.Normal, + color = if (current == Tab.COURSE) primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + // 手势层 + Box( + Modifier + .fillMaxSize() + .then(dampedDragAnimation.modifier) + ) {} + } +} + +/* ==================== 日程标签页 ==================== */ + +@Composable +private fun ScheduleTab( + works: List, + customEvents: List, + syncing: Boolean, + syncMsg: com.ahu.ahutong.ui.screen.xuexiaotong.SyncProgress, + showDone: Boolean, + doneGray: Boolean, + year: Int, + month: Int, + onChangeMonth: (Int) -> Unit, + onWorkClick: (Work) -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + // 日历卡片 + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column( + modifier = Modifier + .padding(12.dp) + .fillMaxWidth() + .pointerInput(Unit) { + var accumulated = 0f + var started = false + detectHorizontalDragGestures( + onDragStart = { accumulated = 0f }, + onDragEnd = { + if (started) { + if (accumulated < -40f) onChangeMonth(1) + else if (accumulated > 40f) onChangeMonth(-1) + } + }, + onDragCancel = {} + ) { change, dragAmount -> + change.consume() + started = true + accumulated += dragAmount + } + } + ) { + // 星期表头 + Row(Modifier.fillMaxWidth()) { + listOf("日", "一", "二", "三", "四", "五", "六").forEach { day -> + Text( + day, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium + ) + } + } + Spacer(Modifier.height(6.dp)) + + // 月份滑动动画 + val monthKey = year * 12 + (month - 1) + AnimatedContent( + targetState = monthKey, + transitionSpec = { + if (targetState > initialState) { + (slideInHorizontally { it } + fadeIn(tween(260))) togetherWith + (slideOutHorizontally { -it / 2 } + fadeOut(tween(220))) + } else { + (slideInHorizontally { -it } + fadeIn(tween(260))) togetherWith + (slideOutHorizontally { it / 2 } + fadeOut(tween(220))) + } + }, + label = "monthContent" + ) { key -> + val y = key / 12 + val m = key % 12 + 1 + val model = remember(key, works, customEvents, showDone) { + CalendarModel.buildMonth(y, m, works, customEvents, showDone) + } + Column { + model.rows.forEachIndexed { ri, row -> + MonthRowView(row = row, isShade = ri % 2 == 1, doneGray = doneGray, onWorkClick = onWorkClick) + } + if (model.noWorks) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(6.dp) + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), + CircleShape + ) + ) + Spacer(Modifier.width(6.dp)) + Text( + "本月暂无作业安排", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Spacer(Modifier.height(8.dp)) + } + } + } + } + Spacer(Modifier.height(60.dp)) + } +} + +@Composable +private fun MonthRowView( + row: com.ahu.ahutong.ui.screen.xuexiaotong.CalendarRow, + isShade: Boolean, + doneGray: Boolean, + onWorkClick: (Work) -> Unit +) { + val cellBg = if (isShade) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.02f) else Color.Transparent + + Column(Modifier.fillMaxWidth()) { + // 日期层 + Row(Modifier.fillMaxWidth().background(cellBg)) { + row.cells.forEach { cell -> + Box( + modifier = Modifier + .weight(1f) + .height(34.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(24.dp) + .background( + if (cell.isToday) MaterialTheme.colorScheme.primary.copy(alpha = 0.16f) else Color.Transparent, + CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + if (cell.day == 0) "" else "${cell.day}", + fontSize = 12.sp, + color = when { + cell.isToday -> MaterialTheme.colorScheme.primary + cell.outside -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + else -> MaterialTheme.colorScheme.onSurface + }, + fontWeight = if (cell.isToday) FontWeight.Bold else FontWeight.Normal + ) + } + } + } + } + // 色块层 + if (row.blocks.isNotEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height((row.layerHeight).dp) + .padding(top = 2.dp) + ) { + row.blocks.forEach { b -> + WorkBlockView(b, doneGray, onWorkClick) + } + } + } else { + Spacer(Modifier.height(CalendarModel.LAYER_PAD.dp)) + } + } +} + +@Composable +private fun WorkBlockView( + b: com.ahu.ahutong.ui.screen.xuexiaotong.CalendarBlock, + doneGray: Boolean, + onWorkClick: (Work) -> Unit +) { + val cellPct = 100f / 7f + val leftPct = b.colStart * cellPct + val widthPct = (b.colEnd - b.colStart + 1) * cellPct + val topPx = b.lane * CalendarModel.BLOCK_STEP + + var parentWidth by remember { mutableStateOf(0) } + val density = LocalDensity.current + + val bg = try { Color(android.graphics.Color.parseColor(b.work.colorBg)) } catch (e: Exception) { Color(0xFF9E9E9E) } + val text = try { Color(android.graphics.Color.parseColor(b.work.colorText)) } catch (e: Exception) { Color(0xFF9E9E9E) } + val isDone = b.work.isDone + + val finalBg = if (isDone && doneGray) bg.copy(alpha = 0.25f) else bg.copy(alpha = 0.7f) + val finalText = if (isDone && doneGray) text.copy(alpha = 0.4f) else text + + Box( + modifier = Modifier + .fillMaxWidth() + .onSizeChanged { parentWidth = it.width } + ) { + Box( + modifier = Modifier + .fillMaxWidth(widthPct / 100f) + .offset { + IntOffset( + x = (parentWidth * leftPct / 100f).roundToInt(), + y = with(density) { topPx.dp.roundToPx() } + ) + } + .padding(horizontal = 1.dp) + .height(CalendarModel.BLOCK_H.dp) + .background(finalBg, RoundedCornerShape(6.dp)) + .clickable { onWorkClick(b.work) }, + contentAlignment = Alignment.CenterStart + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + b.work.title, + modifier = Modifier.weight(1f), + fontSize = 9.sp, + lineHeight = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = finalText + ) + if (b.continueNext) { + Text("▸", fontSize = 8.sp, lineHeight = 11.sp, color = finalText) + } + } + } + } +} + +/* ==================== 课程进度标签页 ==================== */ + +@Composable +private fun CourseTab( + progress: List, + syncing: Boolean, + syncMsg: com.ahu.ahutong.ui.screen.xuexiaotong.SyncProgress, + showEmptyCourses: Boolean +) { + val filtered = if (showEmptyCourses) progress + else progress.filter { it.totalCount > 0 } + + if (filtered.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + "暂无课程进度,点击右上角同步获取", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 72.dp) + ) { + item(key = "__overview__") { + CourseOverviewCard(list = filtered) + } + items(filtered, key = { it.courseId }) { p -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + p.name, + modifier = Modifier.weight(1f), + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + if (p.totalCount > 0) "${p.percent}%" else "暂无任务点", + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = if (p.totalCount > 0) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(Modifier.height(8.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(3.dp)) + ) { + Box( + modifier = Modifier + .fillMaxWidth(p.percent / 100f) + .height(6.dp) + .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(3.dp)) + ) + } + Spacer(Modifier.height(8.dp)) + Text( + if (p.totalCount > 0) "已完成任务点 ${p.doneCount}/${p.totalCount}" + else "暂无任务点", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } +} + +@Composable +private fun CourseOverviewCard(list: List) { + val totalDone = list.sumOf { it.doneCount } + val totalAll = list.sumOf { it.totalCount } + val percent = if (totalAll > 0) (totalDone * 100 / totalAll).coerceAtMost(100) else 0 + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Row(Modifier.padding(vertical = 16.dp)) { + CourseOverviewStat( + value = "${list.size}", + label = "门课程", + valueColor = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + CourseOverviewStat( + value = "$percent%", + label = "总进度", + valueColor = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + CourseOverviewStat( + value = if (totalAll > 0) "$totalDone/$totalAll" else "0/0", + label = "已完成任务点", + valueColor = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +private fun CourseOverviewStat( + value: String, + label: String, + valueColor: Color, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + value, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = valueColor + ) + Spacer(Modifier.height(4.dp)) + Text( + label, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongViewModel.kt new file mode 100644 index 00000000..ed49c658 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongViewModel.kt @@ -0,0 +1,217 @@ +package com.ahu.ahutong.ui.screen.xuexiaotong + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.ahu.ahutong.data.xuexiaotong.ChaoxingApi +import com.ahu.ahutong.data.xuexiaotong.Course +import com.ahu.ahutong.data.xuexiaotong.CourseProgress +import com.ahu.ahutong.data.xuexiaotong.CustomEvent +import com.ahu.ahutong.data.xuexiaotong.RemindSetting +import com.ahu.ahutong.data.xuexiaotong.Store +import com.ahu.ahutong.data.xuexiaotong.Work +import com.ahu.ahutong.reminder.ReminderScheduler +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 + +data class SyncProgress(val done: Int = 0, val total: Int = 0, val message: String = "") + +class XuexiaotongViewModel(val api: ChaoxingApi, private val appContext: Context) : ViewModel() { + + private val _loggedIn = MutableStateFlow(api.hasSession()) + val loggedIn: StateFlow = _loggedIn.asStateFlow() + + private val _works = MutableStateFlow>(Store.getWorks()) + val works: StateFlow> = _works.asStateFlow() + + private val _courses = MutableStateFlow>(Store.getCourses()) + val courses: StateFlow> = _courses.asStateFlow() + + private val _progress = MutableStateFlow>(Store.getCourseProgress()) + val progress: StateFlow> = _progress.asStateFlow() + + private val _syncing = MutableStateFlow(false) + val syncing: StateFlow = _syncing.asStateFlow() + + private val _syncProgress = MutableStateFlow(SyncProgress()) + val syncProgress: StateFlow = _syncProgress.asStateFlow() + + private val _courseSyncing = MutableStateFlow(false) + val courseSyncing: StateFlow = _courseSyncing.asStateFlow() + + private val _courseSyncProgress = MutableStateFlow(SyncProgress()) + val courseSyncProgress: StateFlow = _courseSyncProgress.asStateFlow() + + private val _lastSync = MutableStateFlow(Store.getLastSync()) + val lastSync: StateFlow = _lastSync.asStateFlow() + + private val _remindSetting = MutableStateFlow(Store.getRemindSetting()) + val remindSetting: StateFlow = _remindSetting.asStateFlow() + + private val _customEvents = MutableStateFlow>(Store.getCustomEvents()) + val customEvents: StateFlow> = _customEvents.asStateFlow() + + private val _showDone = MutableStateFlow(Store.getShowDone()) + val showDone: StateFlow = _showDone.asStateFlow() + + private val _doneGray = MutableStateFlow(Store.getDoneGray()) + val doneGray: StateFlow = _doneGray.asStateFlow() + + private val _showEmptyCourses = MutableStateFlow(Store.getShowEmptyCourses()) + val showEmptyCourses: StateFlow = _showEmptyCourses.asStateFlow() + + private val _snackbar = MutableStateFlow(null) + val snackbar: StateFlow = _snackbar.asStateFlow() + + fun consumeSnackbar() { _snackbar.value = null } + + fun showMsg(msg: String) { _snackbar.value = msg } + + fun refreshState() { + _loggedIn.value = api.hasSession() + _works.value = Store.getWorks() + _courses.value = Store.getCourses() + _progress.value = Store.getCourseProgress() + _showDone.value = Store.getShowDone() + _doneGray.value = Store.getDoneGray() + _showEmptyCourses.value = Store.getShowEmptyCourses() + } + + fun toggleShowDone() { + val v = !_showDone.value + Store.saveShowDone(v) + _showDone.value = v + } + + fun toggleDoneGray() { + val v = !_doneGray.value + Store.saveDoneGray(v) + _doneGray.value = v + } + + fun toggleShowEmptyCourses() { + val v = !_showEmptyCourses.value + Store.saveShowEmptyCourses(v) + _showEmptyCourses.value = v + } + + fun onLoginSuccess() { + _loggedIn.value = true + refreshState() + syncWorks() + } + + fun logout() { + api.clearSession() + Store.clearLoginData() + Store.clearCredential() + _loggedIn.value = false + _works.value = emptyList() + _courses.value = emptyList() + _progress.value = emptyList() + } + + fun syncWorks() { + if (_syncing.value) return + viewModelScope.launch { + _syncing.value = true + _syncProgress.value = SyncProgress(message = "正在同步作业...") + try { + val works = withContext(Dispatchers.IO) { + api.silentRelogin() + api.syncAllWorks(object : ChaoxingApi.ProgressListener { + override fun onProgress(done: Int, total: Int, message: String) { + _syncProgress.value = SyncProgress(done, total, message) + } + }) + } + _works.value = works + _courses.value = Store.getCourses() + _syncProgress.value = SyncProgress(message = "同步完成") + Store.saveLastSync(System.currentTimeMillis()) + _lastSync.value = Store.getLastSync() + } catch (e: Exception) { + _syncProgress.value = SyncProgress(message = e.message ?: "同步失败") + } finally { + _syncing.value = false + ReminderScheduler.scheduleAll(appContext) + } + } + } + + fun syncCourseProgress() { + if (_courseSyncing.value) return + viewModelScope.launch { + _courseSyncing.value = true + _courseSyncProgress.value = SyncProgress(message = "正在同步课程进度...") + try { + val list = withContext(Dispatchers.IO) { + api.silentRelogin() + api.syncCourseProgress(object : ChaoxingApi.ProgressListener { + override fun onProgress(done: Int, total: Int, message: String) { + _courseSyncProgress.value = SyncProgress(done, total, message) + } + }) + } + _progress.value = list + _courseSyncProgress.value = SyncProgress(message = "同步完成") + Store.saveLastSync(System.currentTimeMillis()) + _lastSync.value = Store.getLastSync() + } catch (e: Exception) { + _courseSyncProgress.value = SyncProgress(message = e.message ?: "同步失败") + } finally { + _courseSyncing.value = false + } + } + } + + fun saveRemind(setting: RemindSetting) { + Store.saveRemindSetting(setting) + _remindSetting.value = setting + ReminderScheduler.rescheduleAll(appContext) + } + + fun sendTestNotification() { + ReminderScheduler.sendTest(appContext) + } + + fun saveCustomEvents(list: List) { + Store.saveCustomEvents(list) + _customEvents.value = list + ReminderScheduler.rescheduleAll(appContext) + } + + fun addCustomEvent(ev: CustomEvent) { + val list = _customEvents.value.toMutableList() + list.add(ev) + saveCustomEvents(list) + } + + fun toggleCustomEventDone(id: String) { + val list = _customEvents.value.map { + if (it.id == id) it.copy(done = !it.done) else it + } + saveCustomEvents(list) + } + + fun deleteCustomEvent(id: String) { + val list = _customEvents.value.filter { it.id != id } + saveCustomEvents(list) + } + + fun clearCustomEvents() { + saveCustomEvents(emptyList()) + } + + class Factory(private val api: ChaoxingApi, private val appContext: Context) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return XuexiaotongViewModel(api, appContext) as T + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_calendar_check.xml b/app/src/main/res/drawable/ic_calendar_check.xml new file mode 100644 index 00000000..f231a1b0 --- /dev/null +++ b/app/src/main/res/drawable/ic_calendar_check.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_xuexiaotong.png b/app/src/main/res/drawable/ic_xuexiaotong.png new file mode 100644 index 0000000000000000000000000000000000000000..0990242a163b9cec92407adc15c42295961c57f2 GIT binary patch literal 372837 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 Date: Wed, 26 Aug 2026 23:00:05 +0800 Subject: [PATCH 02/29] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20xuexiaotong?= =?UTF-8?q?=20=E7=99=BB=E5=BD=95=E7=95=8C=E9=9D=A2=E4=B8=8E=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 debug 签名配置,release 构建使用 debugKey - 优化登录界面文案,补充隐私说明 - 新增学小通开源许可证声明 --- app/build.gradle.kts | 14 ++++++++++++-- .../screen/xuexiaotong/XuexiaotongLoginScreen.kt | 7 ++++++- .../com/ahu/ahutong/ui/state/LicenseViewModel.kt | 6 ++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1b3df162..805d80bf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,6 +11,15 @@ android { namespace = "com.ahu.ahutong" compileSdk = 36 + signingConfigs { + create("debugKey") { + storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } + sourceSets { getByName("main") { jniLibs.srcDirs("src/main/jniLibs") @@ -45,8 +54,9 @@ android { buildTypes { release { - isShrinkResources = true // 移除无用的resource文件 - isMinifyEnabled = true //是否对代码进行混淆,true表示混淆 + isShrinkResources = true + isMinifyEnabled = true + signingConfig = signingConfigs.getByName("debugKey") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongLoginScreen.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongLoginScreen.kt index 8520f472..f416bef9 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongLoginScreen.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongLoginScreen.kt @@ -79,10 +79,15 @@ fun XuexiaotongLoginScreen( ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "记得交作业哦~", + text = "使用学习通账号密码登录", fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant ) + Text( + text = "登陆凭证仅用于登录学习通,不会上传到任何第三方", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) Spacer(modifier = Modifier.height(32.dp)) Column( 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..b8882356 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 @@ -98,6 +98,12 @@ class LicenseViewModel : ViewModel() { "licenses/guixu/LICENSE", "licenses/guixu/NOTICE" ), + License( + "学小通", + "InChange-Jiang", + "https://github.com/InChange-Jiang/Xuexiaotong", + "Apache License 2.0" + ), License( "More...", "Various Developers", From 47e4146c1f568b57e0e15c1099288fb6b7110dda Mon Sep 17 00:00:00 2001 From: InChange-Jiang <316875401+InChange-Jiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:16:19 +0800 Subject: [PATCH 03/29] =?UTF-8?q?fix:=20=E6=A0=B9=E6=8D=AE=20PR=20?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E6=84=8F=E8=A7=81=E4=BF=AE=E5=A4=8D=2010=20?= =?UTF-8?q?=E4=B8=AA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 release debugKey 签名配置 - 新增 POST_NOTIFICATIONS 权限申请 - 修复重启后提醒丢失、退出/删除后旧提醒残留 - 修复'仅提醒未完成'条件判断 - 修复同步失败覆盖旧数据、课程进度泄露 - CookieJar 使用 matches(url) 过滤 - 每次同步刷新课程列表和截止时间 - 所有请求使用 use {} 关闭 Response --- app/build.gradle.kts | 10 --- app/src/main/AndroidManifest.xml | 2 +- .../ahutong/data/xuexiaotong/ChaoxingApi.kt | 68 +++++++++++-------- .../data/xuexiaotong/PersistentCookieJar.kt | 10 +-- .../com/ahu/ahutong/data/xuexiaotong/Store.kt | 3 + .../com/ahu/ahutong/reminder/BootReceiver.kt | 4 +- .../ahu/ahutong/reminder/ReminderScheduler.kt | 2 +- .../ui/screen/xuexiaotong/RemindDialog.kt | 50 ++++++++++---- .../xuexiaotong/XuexiaotongViewModel.kt | 6 +- gradle/wrapper/gradle-wrapper.properties | 2 +- settings.gradle.kts | 12 ---- 11 files changed, 97 insertions(+), 72 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 805d80bf..af874852 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,15 +11,6 @@ android { namespace = "com.ahu.ahutong" compileSdk = 36 - signingConfigs { - create("debugKey") { - storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore") - storePassword = "android" - keyAlias = "androiddebugkey" - keyPassword = "android" - } - } - sourceSets { getByName("main") { jniLibs.srcDirs("src/main/jniLibs") @@ -56,7 +47,6 @@ android { release { isShrinkResources = true isMinifyEnabled = true - signingConfig = signingConfigs.getByName("debugKey") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 087e79a0..cf2333b6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -163,7 +163,7 @@ - + diff --git a/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/ChaoxingApi.kt b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/ChaoxingApi.kt index 9cae5abf..30307aa8 100644 --- a/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/ChaoxingApi.kt +++ b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/ChaoxingApi.kt @@ -68,16 +68,16 @@ class ChaoxingApi(private val context: Context) { throw IOException(msg) } val jumpUrl = data.optString("url", "") - try { if (jumpUrl.isNotEmpty()) get(jumpUrl) } catch (e: Exception) { } + try { if (jumpUrl.isNotEmpty()) getText(jumpUrl) } catch (e: Exception) { } val domains = listOf( - "https://mooc2-ans.chaoxing.com/visit/interaction", - "https://mooc1.chaoxing.com/visit/interaction", - "https://mobilelearn.chaoxing.com/page/active/stuActiveList?courseid=1&clazzid=1&cpi=1&ut=s&t=${System.currentTimeMillis()}&stuenc=1&fid=1", - "https://i.mooc.chaoxing.com/space/index", - "https://passport2-api.chaoxing.com/", - "https://stat2-ans.chaoxing.com/" - ) - for (d in domains) { try { get(d) } catch (e: Exception) { } } + "https://mooc2-ans.chaoxing.com/visit/interaction", + "https://mooc1.chaoxing.com/visit/interaction", + "https://mobilelearn.chaoxing.com/page/active/stuActiveList?courseid=1&clazzid=1&cpi=1&ut=s&t=${System.currentTimeMillis()}&stuenc=1&fid=1", + "https://i.mooc.chaoxing.com/space/index", + "https://passport2-api.chaoxing.com/", + "https://stat2-ans.chaoxing.com/" + ) + for (d in domains) { try { getText(d) } catch (e: Exception) { } } val cookie = cookieJar.cookieString() Store.saveCookie(cookie) @@ -375,13 +375,14 @@ class ChaoxingApi(private val context: Context) { suspend fun syncAllWorks(listener: ProgressListener? = null): List = withContext(Dispatchers.IO) { listener?.onProgress(0, 0, "正在获取课程列表...") - var courses = Store.getCourses() - if (courses.isEmpty()) courses = fetchCourses() + // 每次同步都刷新课程列表,新加入的课程无需退出登录即可看到 + var courses = try { fetchCourses() } catch (e: Exception) { Store.getCourses() } if (courses.isEmpty()) throw IOException("课程列表获取为空,请稍后重试") val existing = Store.getWorks() - val existingMap = existing.filter { it.workId.isNotEmpty() && it.endTs != null } - .associateBy { it.workId } + val existingByCourse = existing.groupBy { it.courseId }.mapValues { (_, v) -> + v.associateBy { it.workId } + } val allWorks = mutableListOf() var done = 0 @@ -396,22 +397,30 @@ class ChaoxingApi(private val context: Context) { delay(600) for (work in works) { - val prev = existingMap[work.workId] - var startTs: Long? = prev?.startTs - var endTs: Long? = prev?.endTs - - if (prev == null) { - try { - val dl = fetchWorkDeadline(work) - if (dl != null) { startTs = dl.first; endTs = dl.second } - delay(800) - } catch (e: Exception) { } + var startTs: Long? = null + var endTs: Long? = null + + // 每次同步都重新抓取截止时间,确保延期后的时间更新 + try { + val dl = fetchWorkDeadline(work) + if (dl != null) { startTs = dl.first; endTs = dl.second } + delay(800) + } catch (e: Exception) { + // 抓取失败时使用旧数据 + val prev = existingByCourse[course.courseId]?.get(work.workId) + startTs = prev?.startTs + endTs = prev?.endTs } + val prev = existingByCourse[course.courseId]?.get(work.workId) allWorks.add(work.copy(startTs = startTs, endTs = endTs, rawStart = prev?.rawStart ?: "", rawEnd = prev?.rawEnd ?: "")) } - } catch (e: Exception) { } + } catch (e: Exception) { + // 单门课程失败时保留旧数据,避免该课程作业从日历消失 + val oldCourseWorks = existingByCourse[course.courseId]?.values ?: emptyList() + allWorks.addAll(oldCourseWorks) + } done++ listener?.onProgress(done, total, "已完成 $done/$total 门课程") } @@ -429,10 +438,11 @@ class ChaoxingApi(private val context: Context) { suspend fun syncCourseProgress(listener: ProgressListener? = null): List = withContext(Dispatchers.IO) { listener?.onProgress(0, 0, "正在获取课程进度...") - var courses = Store.getCourses() - if (courses.isEmpty()) courses = fetchCourses() + // 每次同步都刷新课程列表 + var courses = try { fetchCourses() } catch (e: Exception) { Store.getCourses() } if (courses.isEmpty()) throw IOException("课程列表获取为空,请稍后重试") + val existingProgress = Store.getCourseProgress().associateBy { it.courseId } val result = mutableListOf() var done = 0 val total = courses.size @@ -453,7 +463,11 @@ class ChaoxingApi(private val context: Context) { course.name, finish, jobcount, percent, System.currentTimeMillis())) } delay(800) - } catch (e: Exception) { } + } catch (e: Exception) { + // 单门课程失败时保留旧数据 + val old = existingProgress[course.courseId] + if (old != null) result.add(old) + } done++ listener?.onProgress(done, total, "已完成 $done/$total 门课程") } diff --git a/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/PersistentCookieJar.kt b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/PersistentCookieJar.kt index 7351626e..9733eae8 100644 --- a/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/PersistentCookieJar.kt +++ b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/PersistentCookieJar.kt @@ -17,7 +17,7 @@ class PersistentCookieJar(private val context: Context) : CookieJar { override fun saveFromResponse(url: HttpUrl, cookies: List) { val list = cache.getOrPut(url.host) { mutableListOf() } cookies.forEach { c -> - list.removeAll { it.name == c.name } + list.removeAll { it.name == c.name && it.domain == c.domain && it.path == c.path } list.add(c) } persist() @@ -25,13 +25,13 @@ class PersistentCookieJar(private val context: Context) : CookieJar { override fun loadForRequest(url: HttpUrl): List { val now = System.currentTimeMillis() - val map = linkedMapOf() + val result = mutableListOf() cache.forEach { (_, list) -> - list.filter { it.expiresAt > now }.forEach { c -> - map[c.name] = c + list.filter { it.expiresAt > now && it.matches(url) }.forEach { c -> + result.add(c) } } - return map.values.toList() + return result } fun cookieString(): String { diff --git a/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/Store.kt b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/Store.kt index 0f573ae7..5521f805 100644 --- a/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/Store.kt +++ b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/Store.kt @@ -159,5 +159,8 @@ object Store { remove("cx_works") remove("cx_last_sync") remove("cx_reminded") + remove("cx_course_progress") + remove("cx_cred_phone") + remove("cx_cred_pwd") } } \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/reminder/BootReceiver.kt b/app/src/main/java/com/ahu/ahutong/reminder/BootReceiver.kt index e91f8d67..95705a49 100644 --- a/app/src/main/java/com/ahu/ahutong/reminder/BootReceiver.kt +++ b/app/src/main/java/com/ahu/ahutong/reminder/BootReceiver.kt @@ -3,6 +3,7 @@ package com.ahu.ahutong.reminder import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import com.ahu.ahutong.data.xuexiaotong.Store class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -10,8 +11,9 @@ class BootReceiver : BroadcastReceiver() { Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_LOCKED_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED, - Intent.ACTION_TIME_CHANGED, + "android.intent.action.TIME_SET", Intent.ACTION_TIMEZONE_CHANGED -> { + Store.saveRemindedMap(emptyMap()) ReminderScheduler.scheduleAll(context) } } diff --git a/app/src/main/java/com/ahu/ahutong/reminder/ReminderScheduler.kt b/app/src/main/java/com/ahu/ahutong/reminder/ReminderScheduler.kt index 5753dff2..feefc8c0 100644 --- a/app/src/main/java/com/ahu/ahutong/reminder/ReminderScheduler.kt +++ b/app/src/main/java/com/ahu/ahutong/reminder/ReminderScheduler.kt @@ -59,7 +59,7 @@ object ReminderScheduler { works.forEach { w -> val endTs = w.endTs ?: return@forEach if (endTs <= now) return@forEach - if (w.isDone && !setting.onlyTodo) return@forEach + if (w.isDone && setting.onlyTodo) return@forEach val remindAt = endTs - setting.leadMinutes * 60000L if (remindAt <= now) return@forEach diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/RemindDialog.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/RemindDialog.kt index 5d62d67b..8326531b 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/RemindDialog.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/RemindDialog.kt @@ -1,9 +1,13 @@ package com.ahu.ahutong.ui.screen.xuexiaotong +import android.Manifest import android.app.AlarmManager import android.content.Intent +import android.content.pm.PackageManager import android.os.Build import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -37,6 +41,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog +import androidx.core.content.ContextCompat import com.ahu.ahutong.data.xuexiaotong.RemindSetting import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.kyant.monet.n1 @@ -45,17 +50,39 @@ import com.kyant.monet.withNight @OptIn(ExperimentalLayoutApi::class) @Composable fun RemindDialog( - setting: RemindSetting, - onSave: (RemindSetting) -> Unit, - onDismiss: () -> Unit, - onTest: () -> Unit + setting: RemindSetting, + onSave: (RemindSetting) -> Unit, + onDismiss: () -> Unit, + onTest: () -> Unit ) { - var enabled by remember { mutableStateOf(setting.enabled) } - var lead by remember { mutableIntStateOf(setting.leadMinutes) } - var onlyTodo by remember { mutableStateOf(setting.onlyTodo) } + var enabled by remember { mutableStateOf(setting.enabled) } + var lead by remember { mutableIntStateOf(setting.leadMinutes) } + var onlyTodo by remember { mutableStateOf(setting.onlyTodo) } - val leadOptions = listOf(0 to "截止时", 60 to "提前1小时", 360 to "提前6小时", 720 to "提前12小时", 1440 to "提前1天") - val context = LocalContext.current + val leadOptions = listOf(0 to "截止时", 60 to "提前1小时", 360 to "提前6小时", 720 to "提前12小时", 1440 to "提前1天") + val context = LocalContext.current + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) { + onSave(RemindSetting(enabled, lead, onlyTodo)) + onDismiss() + } + } + + fun doSave() { + if (enabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + return + } + } + onSave(RemindSetting(enabled, lead, onlyTodo)) + onDismiss() + } Dialog(onDismissRequest = onDismiss) { Column( @@ -228,10 +255,7 @@ fun RemindDialog( .height(40.dp) .clip(RoundedCornerShape(20.dp)) .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)) - .clickable { - onSave(RemindSetting(enabled, lead, onlyTodo)) - onDismiss() - }, + .clickable { doSave() }, contentAlignment = Alignment.Center ) { Text( diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongViewModel.kt index ed49c658..249dcc7c 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongViewModel.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongViewModel.kt @@ -107,6 +107,7 @@ class XuexiaotongViewModel(val api: ChaoxingApi, private val appContext: Context } fun logout() { + ReminderScheduler.cancelAll(appContext) api.clearSession() Store.clearLoginData() Store.clearCredential() @@ -181,9 +182,12 @@ class XuexiaotongViewModel(val api: ChaoxingApi, private val appContext: Context } fun saveCustomEvents(list: List) { + // 先取消旧列表的所有提醒(cancelAll 从 Store 读取,必须在保存新列表之前) + ReminderScheduler.cancelAll(appContext) + Store.saveRemindedMap(emptyMap()) Store.saveCustomEvents(list) _customEvents.value = list - ReminderScheduler.rescheduleAll(appContext) + ReminderScheduler.scheduleAll(appContext) } fun addCustomEvent(ev: CustomEvent) { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 069df9ef..bad7c246 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle.kts b/settings.gradle.kts index ed2472a9..c6ece244 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 { @@ -26,9 +17,6 @@ dependencyResolutionManagement { 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") } } From 3f1bd27be03a0cb54538acc97878ff04b656ce66 Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Thu, 27 Aug 2026 18:54:24 +0800 Subject: [PATCH 04/29] feat(recharge): unify secure card payment flows --- .../main/java/com/ahu/ahutong/MainActivity.kt | 13 +- .../java/com/ahu/ahutong/data/dao/AHUCache.kt | 46 +- .../ahutong/data/dao/PreferencesManager.kt | 12 + .../ahutong/data/model/CardRechargeBank.kt | 12 + .../personalization/action/AppAction.kt | 2 +- .../component/SecurePaymentPasswordDialog.kt | 431 ++++++ .../ui/components/SettingsComponents.kt | 128 +- .../java/com/ahu/ahutong/ui/screen/Main.kt | 14 +- .../com/ahu/ahutong/ui/screen/Settings.kt | 3 +- .../ahutong/ui/screen/main/BathroomDeposit.kt | 104 +- .../ui/screen/main/CardBalanceDeposit.kt | 522 ++++--- .../ahutong/ui/screen/main/CmbCardRecharge.kt | 1297 ++++++++++++++++- .../ui/screen/main/CmbRechargeNativePanel.kt | 508 +++++++ .../ui/screen/main/ElectricityDeposit.kt | 74 +- .../ahutong/ui/screen/main/NetworkRecharge.kt | 84 +- .../ahutong/ui/screen/main/home/CampusCard.kt | 7 +- .../ahutong/ui/screen/settings/Preferences.kt | 19 +- .../ahutong/ui/state/PreferencesViewModel.kt | 50 +- .../java/com/ahu/ahutong/ui/theme/AHUTheme.kt | 58 +- .../screen/main/CmbRechargePageStyleTest.kt | 276 +++- 20 files changed, 3142 insertions(+), 518 deletions(-) create mode 100644 app/src/main/java/com/ahu/ahutong/data/model/CardRechargeBank.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/component/SecurePaymentPasswordDialog.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbRechargeNativePanel.kt diff --git a/app/src/main/java/com/ahu/ahutong/MainActivity.kt b/app/src/main/java/com/ahu/ahutong/MainActivity.kt index 6ae19776..122844b8 100644 --- a/app/src/main/java/com/ahu/ahutong/MainActivity.kt +++ b/app/src/main/java/com/ahu/ahutong/MainActivity.kt @@ -31,7 +31,8 @@ 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.screen.main.CmbRechargeAutomationController import com.ahu.ahutong.ui.state.AboutViewModel import com.ahu.ahutong.ui.state.DiscoveryViewModel import com.ahu.ahutong.ui.state.LoginViewModel @@ -193,6 +194,11 @@ class MainActivity : ComponentActivity() { } } + override fun onPostResume() { + super.onPostResume() + CmbRechargeAutomationController.schedulePreload(this) + } + override fun onStart() { super.onStart() behaviorRuntime.setForeground(true, true) @@ -205,6 +211,11 @@ class MainActivity : ComponentActivity() { super.onStop() } + override fun onDestroy() { + CmbRechargeAutomationController.discard() + super.onDestroy() + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) 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..8b9ed8fc 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 @@ -7,6 +7,7 @@ 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.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 @@ -543,18 +544,47 @@ object AHUCache { 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 + } + ) } /** 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..868ce6a5 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 @@ -17,6 +17,8 @@ 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 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") @@ -248,6 +250,16 @@ class PreferencesManager @Inject constructor(@param:ApplicationContext private v } } + val useBuiltInSecurePasswordKeyboard: Flow = context.dataStore.data.map { prefs -> + prefs[PreferencesKeys.USE_BUILT_IN_SECURE_PASSWORD_KEYBOARD] ?: true + } + + suspend fun setUseBuiltInSecurePasswordKeyboard(value: Boolean) { + context.dataStore.edit { prefs -> + prefs[PreferencesKeys.USE_BUILT_IN_SECURE_PASSWORD_KEYBOARD] = value + } + } + val courseReminderEnabled: Flow = context.dataStore.data.map { prefs -> prefs[PreferencesKeys.COURSE_REMINDER_ENABLED] ?: false } 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/personalization/action/AppAction.kt b/app/src/main/java/com/ahu/ahutong/personalization/action/AppAction.kt index ad73b267..b05d6f4a 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 @@ -210,7 +210,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"), 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/SettingsComponents.kt b/app/src/main/java/com/ahu/ahutong/ui/components/SettingsComponents.kt index 8a282796..b2f2314b 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 @@ -52,6 +52,7 @@ 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.platform.LocalConfiguration import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -517,73 +518,80 @@ fun SettingsSelectRow( ) { var expanded by remember { mutableStateOf(false) } val selectedLabel = choices.firstOrNull { it.value == selected }?.label.orEmpty() + val menuMinWidth = LocalConfiguration.current.screenWidthDp.dp * 0.5f Column(modifier = modifier.fillMaxWidth()) { - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded }, - 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 ) { - Row( - modifier = Modifier - .fillMaxWidth() - .menuAnchor( + SettingsRowText( + title = title, + subtitle = subtitle, + modifier = Modifier.weight(1f) + ) + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + Row( + modifier = Modifier.menuAnchor( type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled = true + ), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = selectedLabel, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge ) - .heightIn(min = 68.dp) - .padding(horizontal = 20.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - SettingsRowText( - title = title, - subtitle = subtitle, - modifier = Modifier.weight(1f) - ) - Text( - text = selectedLabel, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyLarge - ) - ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) - } - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh) - ) { - choices.forEach { choice -> - DropdownMenuItem( - text = { - Text( - text = choice.label, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyLarge - ) - }, - leadingIcon = { - RadioButton( - selected = choice.value == selected, - onClick = null, - colors = RadioButtonDefaults.colors( - selectedColor = MaterialTheme.colorScheme.primary - ) - ) - }, - trailingIcon = { - if (choice.value == selected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + } + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.widthIn(min = menuMinWidth), + matchAnchorWidth = false, + containerColor = 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 - } - ) + ) + } } } } 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..3579fc38 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 @@ -43,7 +43,6 @@ 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.Evaluation import com.ahu.ahutong.ui.screen.main.Exam @@ -312,18 +311,7 @@ fun Main( } 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 - } - } - } - ) + CardBalanceDeposit(navController = navController) } animatedComposable("network_recharge") { diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/Settings.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/Settings.kt index 81f21c68..6539fe77 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 @@ -41,6 +41,7 @@ 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 @@ -141,7 +142,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)) { 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..6b5c65e0 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 @@ -73,8 +73,9 @@ 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.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.ahu.ahutong.ui.state.BathroomDepositViewModel +import com.ahu.ahutong.ui.component.SecurePaymentPasswordDialog import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.withNight @@ -94,7 +95,7 @@ fun BathroomDeposit( LaunchedEffect(payState.value) { when (payState.value) { is PayState.Succeeded, is PayState.Failed -> { - delay(1000) + delay(PAYMENT_RESULT_DISPLAY_DURATION_MS) viewmodel.resetPaymentState() } @@ -470,73 +471,38 @@ fun BathroomDeposit( } - 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 - ) - } + if (showDialog) { + SecurePaymentPasswordDialog( + password = password, + onPasswordChange = { + password = it + errorMsg = null + }, + title = "请输入校园卡密码", + errorMessage = errorMsg, + onDismissRequest = { + showDialog = false + password = "" + errorMsg = null + }, + onConfirm = { confirmedPassword -> + if (confirmedPassword.length == 6) { + showDialog = false + behaviorReporter.organic(AppActionId.CONFIRM_BATHROOM_PAYMENT) + viewmodel.pay( + bathroom = bathroom, + amount = amount, + password = confirmedPassword + ) + } else { + errorMsg = "密码必须是6位数字" } - }, - 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) - } - } - ) - - - } + } + ) + } } } -} +} + +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..bb56a082 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 @@ -11,32 +11,38 @@ 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.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.selection.toggleable 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.layout.widthIn 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.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.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +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 @@ -51,8 +57,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role @@ -65,6 +72,7 @@ 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.CardRechargeBank import com.ahu.ahutong.data.mock.MockScenarioController import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.CardAccountState @@ -75,6 +83,7 @@ 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" @@ -94,17 +103,38 @@ fun CardBalanceDeposit( val cardInfo = viewModel.cardInfo.collectAsState() val accountState by viewModel.accountState.collectAsState() - val paymentState by viewModel.paymentState.collectAsState() + val agriculturalPaymentState by viewModel.paymentState.collectAsState() + val cmbRechargeState by CmbRechargeAutomationController.state.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()) } + var rechargeMethodMenuExpanded by remember { mutableStateOf(selectedRechargeBank == null) } val context = LocalContext.current + val rechargeMethodMenuMinWidth = LocalConfiguration.current.screenWidthDp.dp * 0.5f val focusManager = LocalFocusManager.current val mockRefreshRevision by MockScenarioController.refreshRevisions().collectAsState() val currentUser = remember { AHUCache.getCurrentUser() } val campusCardUserName = currentUser?.name.orEmpty() val campusCardStudentId = currentUser?.xh.orEmpty() + val paymentState = when (selectedRechargeBank) { + CardRechargeBank.CHINA_MERCHANTS_BANK -> cmbRechargeState.toPaymentState() + CardRechargeBank.AGRICULTURAL_BANK -> agriculturalPaymentState + CardRechargeBank.ALIPAY, + null -> PaymentState.Idle + } + + fun selectRechargeBank(bank: CardRechargeBank) { + if (paymentState == PaymentState.Loading) return + selectedRechargeBank = bank + rechargeMethodMenuExpanded = false + AHUCache.setCardRechargeBank(bank) + if (bank == CardRechargeBank.ALIPAY) copyCampusCardInfo = false + viewModel.resetPaymentState() + CmbRechargeAutomationController.resetPaymentState() + CmbRechargeAutomationController.onBankSelected(context, bank) + } LaunchedEffect(Unit) { viewModel.load() @@ -115,6 +145,17 @@ fun CardBalanceDeposit( viewModel.load() } } + + LaunchedEffect(paymentState, selectedRechargeBank) { + if (paymentState is PaymentState.Success || paymentState is PaymentState.Error) { + delay(PAYMENT_RESULT_DISPLAY_DURATION_MS) + if (selectedRechargeBank == CardRechargeBank.CHINA_MERCHANTS_BANK) { + CmbRechargeAutomationController.resetPaymentState() + } else { + viewModel.resetPaymentState() + } + } + } Column( modifier = Modifier @@ -130,13 +171,89 @@ fun CardBalanceDeposit( style = MaterialTheme.typography.headlineMedium ) - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(16.dp)) - .background(100.n1 withNight 20.n1) + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .clip(SmoothRoundedCornerShape(16.dp)) + .background(100.n1 withNight 20.n1) ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "充值方式", + style = MaterialTheme.typography.titleMedium + ) + ExposedDropdownMenuBox( + expanded = rechargeMethodMenuExpanded, + onExpandedChange = { + if (paymentState != PaymentState.Loading) { + rechargeMethodMenuExpanded = !rechargeMethodMenuExpanded + } + } + ) { + Row( + modifier = Modifier.menuAnchor( + type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, + enabled = paymentState != PaymentState.Loading + ), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = selectedRechargeBank?.displayName ?: "请选择", + color = 30.n1 withNight 70.n1 + ) + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = rechargeMethodMenuExpanded + ) + } + ExposedDropdownMenu( + expanded = rechargeMethodMenuExpanded, + onDismissRequest = { + if (selectedRechargeBank != null) rechargeMethodMenuExpanded = false + }, + modifier = Modifier.widthIn(min = rechargeMethodMenuMinWidth), + matchAnchorWidth = false, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 0.dp + ) { + CardRechargeBank.entries.forEach { method -> + val isSelected = method == selectedRechargeBank + DropdownMenuItem( + text = { + Text( + text = method.displayName, + color = 10.n1 withNight 90.n1 + ) + }, + trailingIcon = { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + modifier = Modifier.background( + if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.10f) + } else { + Color.Transparent + } + ), + onClick = { selectRechargeBank(method) } + ) + } + } + } + } + Row( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier @@ -190,13 +307,14 @@ fun CardBalanceDeposit( } - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(16.dp)) - .background(100.n1 withNight 20.n1), - ) { + if (selectedRechargeBank != CardRechargeBank.ALIPAY) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .clip(SmoothRoundedCornerShape(16.dp)) + .background(100.n1 withNight 20.n1), + ) { Text( text = "充值金额", @@ -205,39 +323,40 @@ fun CardBalanceDeposit( ) - 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 - ) - } + 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 + ) + } + } Row( @@ -245,17 +364,35 @@ fun CardBalanceDeposit( .fillMaxWidth() .navigationBarsPadding() .padding(start = 24.dp, top = 16.dp, end = 16.dp, bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = if (selectedRechargeBank == CardRechargeBank.ALIPAY) { + Arrangement.SpaceBetween + } else { + Arrangement.End + } ) { - 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)) + if (selectedRechargeBank == CardRechargeBank.ALIPAY) { + Row( + modifier = Modifier + .clip(SmoothRoundedCornerShape(12.dp)) + .toggleable( + value = copyCampusCardInfo, + role = Role.Checkbox, + onValueChange = { copyCampusCardInfo = it } + ) + .padding(end = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = copyCampusCardInfo, + onCheckedChange = null + ) + Text( + text = "复制校园卡信息", + style = MaterialTheme.typography.bodyMedium + ) + } + } Box( modifier = Modifier .clip(SmoothRoundedCornerShape(32.dp)) @@ -275,14 +412,61 @@ fun CardBalanceDeposit( PaymentState.Idle -> { CompositionLocalProvider(LocalIndication provides ripple(color = 0.n1)) { Text( - text = "确认", + text = if (selectedRechargeBank == CardRechargeBank.ALIPAY) { + "前往支付宝" + } else { + "确认" + }, modifier = Modifier .clickable( role = Role.Button, onClick = { - if (amount.isNotEmpty()) { - showConfirmDialog = true // 点击显示弹窗 - } + when (selectedRechargeBank) { + CardRechargeBank.ALIPAY -> { + showAlipayConfirmDialog = true + } + + CardRechargeBank.CHINA_MERCHANTS_BANK -> { + if (amount.isNotEmpty() && + accountState is CardAccountState.Ready + ) { + behaviorReporter.organic( + AppActionId.SUBMIT_CMB_CARD_RECHARGE + ) + CmbRechargeAutomationController.submit( + context = context, + amount = amount + ) + } else if (amount.isNotEmpty()) { + Toast.makeText( + context, + "校园卡账户仍在加载,请稍后重试", + Toast.LENGTH_SHORT + ).show() + } + } + + CardRechargeBank.AGRICULTURAL_BANK -> { + if (amount.isNotEmpty() && + accountState is CardAccountState.Ready + ) { + behaviorReporter.organic( + AppActionId.SUBMIT_CARD_RECHARGE + ) + viewModel.charge(amount) + } else if (amount.isNotEmpty()) { + Toast.makeText( + context, + "校园卡账户仍在加载,请稍后重试", + Toast.LENGTH_SHORT + ).show() + } + } + + null -> { + rechargeMethodMenuExpanded = true + } + } } ) .padding(24.dp, 16.dp), @@ -356,13 +540,26 @@ fun CardBalanceDeposit( modifier = Modifier.size(56.dp), tint = 100.n1 ) - Text( - text = "支付成功!订单号:${state.orderId}", - modifier = Modifier - .padding(4.dp) - .clickable { - viewModel.resetPaymentState() - }, + Text( + text = if ( + selectedRechargeBank == CardRechargeBank.CHINA_MERCHANTS_BANK + ) { + "支付成功!请刷卡将过渡余额转入校园卡" + } else { + "支付成功!订单号:${state.orderId}" + }, + modifier = Modifier + .padding(4.dp) + .clickable { + if ( + selectedRechargeBank == + CardRechargeBank.CHINA_MERCHANTS_BANK + ) { + CmbRechargeAutomationController.resetPaymentState() + } else { + viewModel.resetPaymentState() + } + }, color = 100.n1, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.headlineSmall @@ -375,44 +572,25 @@ fun CardBalanceDeposit( } - if (showConfirmDialog) { - AlertDialog( - - containerColor = 100.n1 withNight 20.n1, - titleContentColor = 10.n1 withNight 90.n1, - onDismissRequest = { showConfirmDialog = false }, - title = { Text("确认支付") }, + if (showAlipayConfirmDialog) { + AlertDialog( + containerColor = 100.n1 withNight 20.n1, + titleContentColor = 10.n1 withNight 90.n1, + onDismissRequest = { showAlipayConfirmDialog = 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 - ) - } + Text( + "确认打开支付宝校园卡充值页面?", + color = 40.n1 withNight 60.n1 + ) }, confirmButton = { - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Text( - text = "支付宝支付", - modifier = Modifier - .clickable { - behaviorReporter.organic(AppActionId.SUBMIT_CARD_RECHARGE) + Text( + text = "确认", + modifier = Modifier + .clickable { + behaviorReporter.organic(AppActionId.SUBMIT_CARD_RECHARGE) + if (copyCampusCardInfo) { val identityState = copyCampusCardIdentity( context = context, name = campusCardUserName, @@ -424,34 +602,19 @@ 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 - ) - } + openAlipayCampusCard(context) + showAlipayConfirmDialog = false + } + .padding(8.dp), + color = 10.n1 withNight 90.n1 + ) }, dismissButton = { Text( text = "取消", - modifier = Modifier - .clickable { showConfirmDialog = false } + modifier = Modifier + .clickable { showAlipayConfirmDialog = false } .padding(8.dp), color = 10.n1 withNight 90.n1 ) @@ -459,52 +622,10 @@ fun CardBalanceDeposit( ) } - 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") - } - .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 - ) - } - } + if (cmbRechargeState.phase == CmbRechargePaymentPhase.PASSWORD_REQUIRED) { + CmbRechargeQueryPasswordDialog( + onCancel = CmbRechargeAutomationController::cancelPassword, + onConfirm = CmbRechargeAutomationController::submitPassword ) } @@ -512,6 +633,24 @@ fun CardBalanceDeposit( } +private val CardRechargeBank.displayName: String + get() = when (this) { + CardRechargeBank.AGRICULTURAL_BANK -> "中国农业银行" + CardRechargeBank.CHINA_MERCHANTS_BANK -> "招商银行" + CardRechargeBank.ALIPAY -> "支付宝" + } + +private fun CmbRechargeAutomationState.toPaymentState(): PaymentState = when (phase) { + CmbRechargePaymentPhase.IDLE, + CmbRechargePaymentPhase.PASSWORD_REQUIRED -> PaymentState.Idle + + CmbRechargePaymentPhase.LOADING -> PaymentState.Loading + CmbRechargePaymentPhase.SUCCESS -> PaymentState.Success("招商银行") + CmbRechargePaymentPhase.ERROR -> PaymentState.Error( + errorMessage ?: "招商银行充值失败,请重试" + ) +} + private enum class CampusCardIdentityCopyState { Complete, Partial, @@ -529,9 +668,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 +682,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 index 25759370..16ec21da 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbCardRecharge.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbCardRecharge.kt @@ -1,16 +1,25 @@ package com.ahu.ahutong.ui.screen.main import android.annotation.SuppressLint +import android.app.Activity import android.content.ActivityNotFoundException +import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build +import android.os.Looper +import android.os.MessageQueue import android.os.SystemClock +import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout import android.widget.Toast import android.webkit.WebChromeClient import android.webkit.JavascriptInterface import android.webkit.WebResourceError import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient @@ -51,22 +60,37 @@ 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.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.zIndex 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.data.dao.AHUCache +import com.ahu.ahutong.data.model.CardRechargeBank import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.personalization.action.AppActionId import com.ahu.ahutong.personalization.ui.rememberBehaviorActionReporter +import com.google.gson.Gson import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import okhttp3.Cookie import java.net.URI +import kotlin.coroutines.resume internal data class CmbRechargeNormalizedBounds( val left: Float, @@ -75,6 +99,11 @@ internal data class CmbRechargeNormalizedBounds( val height: Float ) +private const val CMB_NATIVE_BOOTSTRAP_TIMEOUT_MS = 15_000L +private const val CMB_PASSWORD_DISPATCH_TIMEOUT_MS = 15_000L +private const val CMB_SUCCESS_CONFIRMATION_TIMEOUT_MS = 8_000L +internal const val CMB_RECHARGE_PRELOAD_VALIDITY_MS = 3 * 60 * 1_000L + private const val CMB_SUBMIT_OBSERVER_SCRIPT = """ (function(){ if (window.__ahutongSubmitObserverInstalled) return; @@ -96,6 +125,605 @@ private const val CMB_SUBMIT_OBSERVER_SCRIPT = """ })(); """ +private val CMB_NATIVE_STATE_BRIDGE_SCRIPT = """ +(function(){ + function findRechargeComponent(){ + var root = document.querySelector('#app'); + var queue = root && root.__vue__ ? [root.__vue__] : []; + var seen = []; + while (queue.length) { + var current = queue.shift(); + if (!current || seen.indexOf(current) >= 0) continue; + seen.push(current); + if (typeof current.rechargeOrders === 'function' && Array.isArray(current.cardList)) { + return current; + } + if (current.${'$'}children) queue = queue.concat(current.${'$'}children); + } + return null; + } + function publish(){ + var component = findRechargeComponent(); + if (!component || !component.cardList.length) return; + var account = component.cardList[component.cardIndex || 0] || component.cardList[0]; + var methods = (component.payType || []).map(function(item, index){ + return { + pageIndex: index, + name: String(item.payPrdName || ('支付方式 ' + (index + 1))) + }; + }); + var payload = JSON.stringify({ + studentNumber: String(account.empno || ''), + balance: Number(account.balance || 0), + paymentMethods: methods + }); + if (payload === window.__ahutongRechargeLastPayload) return; + window.__ahutongRechargeLastPayload = payload; + window.AhuTongRechargeBridge.onRechargeState(payload); + } + window.__ahutongPublishRechargeState = publish; + if (!window.__ahutongRechargeStateObserverInstalled) { + window.__ahutongRechargeStateObserverInstalled = true; + window.setInterval(publish, 500); + } + publish(); +})(); +""" + +private const val CMB_NATIVE_PAYMENT_UI_SCRIPT = """ +(function(){ + function visible(node){ + if (!node) return false; + var style = window.getComputedStyle(node); + return style.display !== 'none' && style.visibility !== 'hidden' && + node.getClientRects().length > 0; + } + function notify(){ + var sheets = Array.from(document.querySelectorAll('.van-action-sheet')); + var sheet = sheets.find(visible); + var title = sheet + ? ((sheet.querySelector('.van-action-sheet__header') || {}).innerText || '') + : ''; + var passwordDots = sheet + ? Array.from(sheet.querySelectorAll('.van-password-input__security i')).filter(visible).length + : 0; + var toast = Array.from(document.querySelectorAll('.van-toast--fail')).find(visible); + window.AhuTongRechargeBridge.onPaymentUiState(JSON.stringify({ + passwordRequired: title.indexOf('查询密码') >= 0 && passwordDots === 0, + error: toast ? (toast.innerText || '') : '' + })); + } + if (!window.__ahutongPaymentUiObserverInstalled) { + if (!document.body) return 'body-not-ready'; + var observer = new MutationObserver(notify); + observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['style', 'class'] + }); + window.__ahutongPaymentUiObserverInstalled = true; + window.__ahutongPaymentUiObserver = observer; + window.setInterval(notify, 250); + } + notify(); +})(); +""" + +internal enum class CmbRechargePaymentPhase { + IDLE, + LOADING, + PASSWORD_REQUIRED, + SUCCESS, + ERROR +} + +internal data class CmbRechargeAutomationState( + val phase: CmbRechargePaymentPhase = CmbRechargePaymentPhase.IDLE, + val errorMessage: String? = null +) + +internal fun isCmbRechargeSessionFresh( + readyAtElapsedMs: Long, + nowElapsedMs: Long +): Boolean = readyAtElapsedMs > 0L && + nowElapsedMs >= readyAtElapsedMs && + nowElapsedMs - readyAtElapsedMs < CMB_RECHARGE_PRELOAD_VALIDITY_MS + +internal fun canDispatchCmbRecharge( + amount: String?, + password: String?, + hasFreshSession: Boolean +): Boolean = !amount.isNullOrBlank() && + !password.isNullOrBlank() && + hasFreshSession + +internal fun canDispatchCmbPassword( + password: String?, + dispatchInProgress: Boolean, + hasWebView: Boolean +): Boolean = !password.isNullOrBlank() && !dispatchInProgress && hasWebView + +internal fun isCmbSessionExpiredMessage(message: String): Boolean { + val normalized = message.trim().lowercase() + return listOf( + "登录失效", + "登录已失效", + "登录过期", + "登录已过期", + "登录超时", + "会话失效", + "会话已失效", + "会话过期", + "会话已过期", + "请重新登录", + "token失效", + "token已失效" + ).any(normalized::contains) +} + +internal fun shouldRecoverCmbSession( + message: String, + recoveryAttempted: Boolean, + amount: String?, + password: String? +): Boolean = !recoveryAttempted && + !amount.isNullOrBlank() && + !password.isNullOrBlank() && + isCmbSessionExpiredMessage(message) + +/** + * Owns the hidden CMB WebView so the visible recharge screen can stay identical to the + * existing Agricultural Bank flow. The WebView is attached invisibly to keep its Vue/JS + * runtime alive, and every ready session is destroyed after three minutes. + */ +internal object CmbRechargeAutomationController { + private const val TAG = "CmbRechargeAutomation" + private const val PRELOAD_START_DELAY_MS = 1_000L + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val _state = MutableStateFlow(CmbRechargeAutomationState()) + val state: StateFlow = _state.asStateFlow() + + private var webView: WebView? = null + private var hostRoot: ViewGroup? = null + private var nativeData: CmbRechargeNativeData? = null + private var readyAtElapsedMs = 0L + private var generation = 0 + private var pendingAmount: String? = null + private var pendingPassword: String? = null + private var submissionAmount: String? = null + private var submissionPassword: String? = null + private var submissionContext: Context? = null + private var officialPasswordPromptVisible = false + private var passwordDispatchInProgress = false + private var sessionRecoveryAttempted = false + private var userSubmissionActive = false + private var scheduledPreloadJob: Job? = null + private var sessionLoadJob: Job? = null + private var expiryJob: Job? = null + private var bootstrapTimeoutJob: Job? = null + private var paymentTimeoutJob: Job? = null + + fun schedulePreload(activity: Activity) { + if ( + AHUCache.getCardRechargeBank() != CardRechargeBank.CHINA_MERCHANTS_BANK || + !AHUCache.isLogin() || + hasFreshSession() || + sessionLoadJob?.isActive == true || + scheduledPreloadJob?.isActive == true + ) { + return + } + + scheduledPreloadJob = scope.launch { + delay(PRELOAD_START_DELAY_MS) + if ( + AHUCache.getCardRechargeBank() != CardRechargeBank.CHINA_MERCHANTS_BANK || + !AHUCache.isLogin() + ) { + return@launch + } + loadSession(activity, deferWebViewCreationUntilIdle = true) + } + } + + fun onBankSelected(context: Context, bank: CardRechargeBank) { + if (bank == CardRechargeBank.CHINA_MERCHANTS_BANK) { + (context as? Activity)?.let(::schedulePreload) + } + // Keep a fresh CMB session alive while the user compares banks. Its existing + // three-minute expiry remains authoritative and prevents repeated login traffic. + } + + fun submit(context: Context, amount: String) { + scope.launch { + pendingAmount = amount + pendingPassword = null + submissionAmount = amount + submissionPassword = null + submissionContext = context + officialPasswordPromptVisible = false + passwordDispatchInProgress = false + sessionRecoveryAttempted = false + userSubmissionActive = true + _state.value = CmbRechargeAutomationState( + CmbRechargePaymentPhase.PASSWORD_REQUIRED + ) + } + } + + fun submitPassword(password: String) { + if (!userSubmissionActive || pendingAmount == null && !officialPasswordPromptVisible) { + failUserSubmission("招商银行充值会话已失效,请重试") + return + } + + pendingPassword = password + submissionPassword = password + _state.value = CmbRechargeAutomationState(CmbRechargePaymentPhase.LOADING) + if (officialPasswordPromptVisible) { + dispatchPendingPassword() + return + } + + val context = submissionContext + if (context == null) { + failUserSubmission("招商银行充值会话已失效,请重试") + } else if (!hasFreshSession()) { + destroySession() + loadSession(context, deferWebViewCreationUntilIdle = false) + } else { + dispatchPendingRecharge() + } + } + + fun cancelPassword() { + paymentTimeoutJob?.cancel() + if (officialPasswordPromptVisible) webView?.cancelCmbRechargePassword() + pendingAmount = null + pendingPassword = null + submissionAmount = null + submissionPassword = null + submissionContext = null + officialPasswordPromptVisible = false + passwordDispatchInProgress = false + sessionRecoveryAttempted = false + userSubmissionActive = false + _state.value = CmbRechargeAutomationState() + } + + fun resetPaymentState() { + paymentTimeoutJob?.cancel() + pendingAmount = null + pendingPassword = null + submissionAmount = null + submissionPassword = null + submissionContext = null + officialPasswordPromptVisible = false + passwordDispatchInProgress = false + sessionRecoveryAttempted = false + userSubmissionActive = false + _state.value = CmbRechargeAutomationState() + } + + fun discard() { + scope.launch { + scheduledPreloadJob?.cancel() + sessionLoadJob?.cancel() + pendingAmount = null + pendingPassword = null + submissionAmount = null + submissionPassword = null + submissionContext = null + officialPasswordPromptVisible = false + passwordDispatchInProgress = false + sessionRecoveryAttempted = false + userSubmissionActive = false + destroySession() + _state.value = CmbRechargeAutomationState() + } + } + + private fun hasFreshSession(nowElapsedMs: Long = SystemClock.elapsedRealtime()): Boolean = + webView != null && + nativeData != null && + isCmbRechargeNativeEntryUrl(webView?.url) && + isCmbRechargeSessionFresh(readyAtElapsedMs, nowElapsedMs) + + private fun loadSession(context: Context, deferWebViewCreationUntilIdle: Boolean) { + if (sessionLoadJob?.isActive == true) return + val activity = context as? Activity + val applicationContext = context.applicationContext + sessionLoadJob = scope.launch { + val token = withContext(Dispatchers.IO) { TokenManager.awaitToken() } + if (token.isNullOrBlank()) { + handleSessionLoadFailure("校园卡登录凭证暂未就绪,请稍后重试") + return@launch + } + + if (deferWebViewCreationUntilIdle && pendingAmount == null) { + awaitMainThreadIdle() + } + if ( + pendingAmount == null && + AHUCache.getCardRechargeBank() != CardRechargeBank.CHINA_MERCHANTS_BANK + ) { + return@launch + } + + createAndLoadSession( + context = activity ?: applicationContext, + entryUrl = buildCmbRechargeEntryUrl(token) + ) + } + } + + private fun createAndLoadSession(context: Context, entryUrl: String) { + destroySession() + generation += 1 + val sessionGeneration = generation + lateinit var createdView: WebView + createdView = createCmbRechargeWebView( + context = context, + pageBackgroundColor = android.graphics.Color.TRANSPARENT, + pageStyleScript = { "" }, + onLoadingChanged = {}, + onProgressChanged = {}, + onSuccessPageChanged = {}, + onSuccessReturnBoundsChanged = { bounds -> + if ( + sessionGeneration == generation && + shouldConfirmCmbRechargeSuccess(createdView.url, bounds) + ) { + completeUserSubmission() + } + }, + onNativeDataChanged = { data -> + if (sessionGeneration != generation) return@createCmbRechargeWebView + nativeData = data + readyAtElapsedMs = SystemClock.elapsedRealtime() + bootstrapTimeoutJob?.cancel() + scheduleExpiry(sessionGeneration) + dispatchPendingRecharge() + }, + onPaymentUiStateChanged = { requiresPassword, pageError -> + if (sessionGeneration != generation || !userSubmissionActive) { + return@createCmbRechargeWebView + } + if (pageError.isNotBlank()) { + if (!recoverSubmissionAfterSessionExpiry(pageError)) { + failUserSubmission(pageError) + } + } else if (requiresPassword) { + officialPasswordPromptVisible = true + dispatchPendingPassword() + } + }, + onPageChanged = { url -> + Log.d(TAG, "CMB navigation: ${safeCmbPageLocation(url)}") + }, + onMainFrameError = { message -> + if (sessionGeneration == generation) handleSessionLoadFailure(message) + }, + onExternalLink = { + if (sessionGeneration == generation) { + handleSessionLoadFailure("招商银行充值需要打开未受支持的外部页面,请重试") + } + }, + onSubmitIntent = {} + ) + createdView.updateCmbRechargeWebViewVisibility(false) + attachHiddenWebView(context as? Activity, createdView) + syncYcardCookiesToWebView(createdView) + createdView.cmbRechargeState?.requestVersion = sessionGeneration + webView = createdView + createdView.loadUrl(entryUrl) + + bootstrapTimeoutJob = scope.launch { + delay(CMB_NATIVE_BOOTSTRAP_TIMEOUT_MS) + if (sessionGeneration == generation && nativeData == null) { + handleSessionLoadFailure("招商银行充值页面加载超时,请重试") + } + } + } + + private fun attachHiddenWebView(activity: Activity?, view: WebView) { + val root = activity?.findViewById(android.R.id.content) ?: return + hostRoot = root + root.addView( + view, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + } + + private fun dispatchPendingRecharge() { + val amount = pendingAmount ?: return + if (!canDispatchCmbRecharge(amount, pendingPassword, hasFreshSession())) { + if (pendingPassword == null) { + _state.value = CmbRechargeAutomationState( + CmbRechargePaymentPhase.PASSWORD_REQUIRED + ) + } + return + } + val data = nativeData ?: return + val currentView = webView ?: return + val paymentMethod = data.paymentMethods.firstOrNull() + if (paymentMethod == null) { + failUserSubmission("招商银行未找到可用的绑定银行卡") + return + } + + pendingAmount = null + _state.value = CmbRechargeAutomationState(CmbRechargePaymentPhase.LOADING) + startPaymentTimeout("招商银行充值请求超时,请重试") + currentView.submitCmbRecharge( + amount = amount, + paymentMethodIndex = paymentMethod.pageIndex, + onRejected = ::failUserSubmission + ) + } + + private fun dispatchPendingPassword() { + if (passwordDispatchInProgress) return + val password = pendingPassword + val currentView = webView + if ( + !canDispatchCmbPassword( + password = password, + dispatchInProgress = passwordDispatchInProgress, + hasWebView = currentView != null + ) + ) { + _state.value = CmbRechargeAutomationState( + CmbRechargePaymentPhase.PASSWORD_REQUIRED + ) + return + } + val dispatchPassword = password ?: return + val dispatchView = currentView ?: return + + passwordDispatchInProgress = true + pendingPassword = null + _state.value = CmbRechargeAutomationState(CmbRechargePaymentPhase.LOADING) + startPaymentTimeout("查询密码提交超时,请重试") + dispatchView.submitCmbRechargePassword( + password = dispatchPassword, + onRejected = ::failUserSubmission + ) + } + + private fun completeUserSubmission() { + if (!userSubmissionActive) return + paymentTimeoutJob?.cancel() + pendingAmount = null + pendingPassword = null + submissionAmount = null + submissionPassword = null + submissionContext = null + officialPasswordPromptVisible = false + passwordDispatchInProgress = false + sessionRecoveryAttempted = false + userSubmissionActive = false + _state.value = CmbRechargeAutomationState(CmbRechargePaymentPhase.SUCCESS) + scope.launch { + destroySession() + } + } + + private fun startPaymentTimeout(message: String) { + paymentTimeoutJob?.cancel() + paymentTimeoutJob = scope.launch { + delay(CMB_PASSWORD_DISPATCH_TIMEOUT_MS) + if (userSubmissionActive) failUserSubmission(message) + } + } + + private fun recoverSubmissionAfterSessionExpiry(message: String): Boolean { + val amount = submissionAmount + val password = submissionPassword + val context = submissionContext + if ( + context == null || + !shouldRecoverCmbSession( + message = message, + recoveryAttempted = sessionRecoveryAttempted, + amount = amount, + password = password + ) + ) { + return false + } + + sessionRecoveryAttempted = true + paymentTimeoutJob?.cancel() + pendingAmount = amount + pendingPassword = password + officialPasswordPromptVisible = false + passwordDispatchInProgress = false + _state.value = CmbRechargeAutomationState(CmbRechargePaymentPhase.LOADING) + destroySession() + loadSession(context, deferWebViewCreationUntilIdle = false) + return true + } + + private fun failUserSubmission(message: String) { + paymentTimeoutJob?.cancel() + pendingAmount = null + pendingPassword = null + submissionAmount = null + submissionPassword = null + submissionContext = null + officialPasswordPromptVisible = false + passwordDispatchInProgress = false + sessionRecoveryAttempted = false + userSubmissionActive = false + _state.value = CmbRechargeAutomationState( + phase = CmbRechargePaymentPhase.ERROR, + errorMessage = message + ) + scope.launch { destroySession() } + } + + private fun handleSessionLoadFailure(message: String) { + if (userSubmissionActive) { + failUserSubmission(message) + } else { + Log.w(TAG, message) + scope.launch { destroySession() } + } + } + + private fun scheduleExpiry(sessionGeneration: Int) { + expiryJob?.cancel() + expiryJob = scope.launch { + delay(CMB_RECHARGE_PRELOAD_VALIDITY_MS) + if (sessionGeneration == generation && !userSubmissionActive) { + Log.d(TAG, "Discarding expired three-minute CMB preload session") + destroySession() + } + } + } + + private fun destroySession() { + expiryJob?.cancel() + expiryJob = null + bootstrapTimeoutJob?.cancel() + bootstrapTimeoutJob = null + paymentTimeoutJob?.cancel() + paymentTimeoutJob = null + passwordDispatchInProgress = false + nativeData = null + readyAtElapsedMs = 0L + generation += 1 + + val currentView = webView + webView = null + currentView?.cmbRechargeState?.dispose() + (currentView?.parent as? ViewGroup)?.removeView(currentView) + hostRoot = null + currentView?.stopLoading() + currentView?.removeAllViews() + currentView?.destroy() + } + + private suspend fun awaitMainThreadIdle() { + suspendCancellableCoroutine { continuation -> + val queue = Looper.myQueue() + val idleHandler = MessageQueue.IdleHandler { + if (continuation.isActive) continuation.resume(Unit) + false + } + queue.addIdleHandler(idleHandler) + continuation.invokeOnCancellation { queue.removeIdleHandler(idleHandler) } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun CmbCardRecharge( @@ -132,29 +760,69 @@ fun CmbCardRecharge( var loadRequestVersion by remember { mutableIntStateOf(0) } var isLoading by remember { mutableStateOf(true) } var isRechargeSuccessPage by remember { mutableStateOf(false) } + var nativeData by remember { mutableStateOf(null) } + var showWebContent by remember { mutableStateOf(false) } + var forceWebContent by remember { mutableStateOf(false) } + var isSubmitting by remember { mutableStateOf(false) } var successReturnBounds by remember { mutableStateOf(null) } var errorMessage by remember { mutableStateOf(null) } - - BackHandler(onBack = onExit) + var queryPasswordRequired by remember { mutableStateOf(false) } + var nativeSuccess by remember { mutableStateOf(false) } + var isPasswordDispatching by remember { mutableStateOf(false) } + var allowWebContentReveal by remember { mutableStateOf(false) } + val isWebContentVisible = showWebContent || forceWebContent fun reloadEntry() { progress = 0 isLoading = true errorMessage = null isRechargeSuccessPage = false + nativeData = null + showWebContent = false + forceWebContent = false + isSubmitting = false + queryPasswordRequired = false + nativeSuccess = false + isPasswordDispatching = false + allowWebContentReveal = false successReturnBounds = null webView?.stopLoading() loadRequestVersion += 1 } + val handleBack: () -> Unit = { + val currentWebView = webView + if (nativeSuccess) { + latestRechargeSuccessExit.value() + } else if (forceWebContent && isCmbRechargeNativeEntryUrl(currentWebView?.url)) { + forceWebContent = false + allowWebContentReveal = false + } else if (isWebContentVisible && currentWebView?.canGoBack() == true) { + currentWebView.goBack() + } else if (isWebContentVisible) { + reloadEntry() + } else { + onExit() + } + } + BackHandler(onBack = handleBack) + LaunchedEffect(tokenRequestVersion) { progress = 0 isLoading = true errorMessage = null entryUrl = null isRechargeSuccessPage = false + nativeData = null + showWebContent = false + forceWebContent = false + isSubmitting = false + queryPasswordRequired = false + nativeSuccess = false + isPasswordDispatching = false + allowWebContentReveal = false successReturnBounds = null val token = withContext(Dispatchers.IO) { TokenManager.awaitToken() } if (token.isNullOrBlank()) { @@ -168,9 +836,10 @@ fun CmbCardRecharge( DisposableEffect(Unit) { onDispose { - webView?.stopLoading() - webView?.cmbRechargeState?.boundsLocator?.dispose() - webView?.destroy() + val currentView = webView + currentView?.stopLoading() + currentView?.cmbRechargeState?.dispose() + currentView?.destroy() webView = null } } @@ -182,6 +851,45 @@ fun CmbCardRecharge( } } + LaunchedEffect(entryUrl, loadRequestVersion, nativeData, errorMessage, nativeSuccess) { + if ( + entryUrl == null || + nativeData != null || + errorMessage != null || + nativeSuccess + ) { + return@LaunchedEffect + } + delay(CMB_NATIVE_BOOTSTRAP_TIMEOUT_MS) + if (nativeData == null && errorMessage == null && !nativeSuccess) { + isLoading = false + errorMessage = "充值信息加载超时,请检查网络后重试" + } + } + + LaunchedEffect(isPasswordDispatching) { + if (!isPasswordDispatching) return@LaunchedEffect + delay(CMB_PASSWORD_DISPATCH_TIMEOUT_MS) + if (isPasswordDispatching) { + webView?.cancelCmbRechargePassword() + isPasswordDispatching = false + isSubmitting = false + allowWebContentReveal = false + errorMessage = "查询密码提交超时,请重试" + } + } + + LaunchedEffect(isRechargeSuccessPage, nativeSuccess) { + if (!isRechargeSuccessPage || nativeSuccess) return@LaunchedEffect + delay(CMB_SUCCESS_CONFIRMATION_TIMEOUT_MS) + if (isRechargeSuccessPage && !nativeSuccess) { + isSubmitting = false + allowWebContentReveal = true + showWebContent = true + forceWebContent = true + } + } + val pageContentColor = colorScheme.onBackground Scaffold( modifier = Modifier.fillMaxSize(), @@ -189,6 +897,7 @@ fun CmbCardRecharge( contentColor = pageContentColor, topBar = { TopAppBar( + modifier = Modifier.zIndex(1f), title = { Text( text = "招商银行充值", @@ -196,7 +905,7 @@ fun CmbCardRecharge( ) }, navigationIcon = { - IconButton(onClick = onExit) { + IconButton(onClick = handleBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回" @@ -216,11 +925,14 @@ fun CmbCardRecharge( .fillMaxSize() .padding(contentPadding) .background(pageBackgroundColor) + .clipToBounds() ) { entryUrl?.let { url -> val requestVersion = loadRequestVersion AndroidView( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .clipToBounds(), factory = { viewContext -> createCmbRechargeWebView( context = viewContext, @@ -230,11 +942,80 @@ fun CmbCardRecharge( onProgressChanged = { progress = it }, onSuccessPageChanged = { isSuccessPage -> isRechargeSuccessPage = isSuccessPage - if (!isSuccessPage) successReturnBounds = null + if (!isSuccessPage) { + nativeSuccess = false + successReturnBounds = null + } + }, + onSuccessReturnBoundsChanged = { bounds -> + successReturnBounds = bounds + if (shouldConfirmCmbRechargeSuccess(webView?.url, bounds)) { + nativeSuccess = true + errorMessage = null + isSubmitting = false + queryPasswordRequired = false + isPasswordDispatching = false + allowWebContentReveal = false + showWebContent = false + forceWebContent = false + } + }, + onNativeDataChanged = { pageData -> + nativeData = pageData + errorMessage = null + showWebContent = false + }, + onPaymentUiStateChanged = { requiresPassword, pageError -> + queryPasswordRequired = requiresPassword && !isPasswordDispatching + if (pageError.isNotBlank()) { + errorMessage = pageError + isSubmitting = false + isPasswordDispatching = false + allowWebContentReveal = false + } + }, + onPageChanged = { currentUrl -> + if (!isCmbRechargeNativeEntryUrl(currentUrl)) { + queryPasswordRequired = false + isPasswordDispatching = false + } + if ( + isCmbRechargeHiddenFlowUrl(currentUrl) || + isCmbRechargeSuccessUrl(currentUrl) + ) { + if (showWebContent) { + forceWebContent = false + allowWebContentReveal = false + } + showWebContent = false + } else if (isCmbRechargeInsecureEntryUrl(currentUrl)) { + allowWebContentReveal = true + showWebContent = true + forceWebContent = true + isSubmitting = false + } else if ( + shouldRevealCmbRechargeWebContent( + url = currentUrl, + revealAllowed = allowWebContentReveal + ) + ) { + showWebContent = true + isSubmitting = false + } else { + showWebContent = false + } }, - onSuccessReturnBoundsChanged = { successReturnBounds = it }, onMainFrameError = { error -> errorMessage = error + isRechargeSuccessPage = false + nativeSuccess = false + successReturnBounds = null + isSubmitting = false + queryPasswordRequired = false + isPasswordDispatching = false + showWebContent = false + forceWebContent = false + allowWebContentReveal = false }, onExternalLink = { externalUrl -> openExternalLink(context, externalUrl) @@ -243,6 +1024,7 @@ fun CmbCardRecharge( behaviorReporter.organic(AppActionId.SUBMIT_CMB_CARD_RECHARGE) } ).also { created -> + created.updateCmbRechargeWebViewVisibility(isWebContentVisible) syncYcardCookiesToWebView(created) created.cmbRechargeState?.requestVersion = requestVersion created.loadUrl(url) @@ -251,6 +1033,7 @@ fun CmbCardRecharge( }, update = { currentView -> currentView.setBackgroundColor(pageBackgroundColor.toArgb()) + currentView.updateCmbRechargeWebViewVisibility(isWebContentVisible) if (currentView.cmbRechargeState?.requestVersion != requestVersion) { syncYcardCookiesToWebView(currentView) currentView.cmbRechargeState?.requestVersion = requestVersion @@ -261,7 +1044,7 @@ fun CmbCardRecharge( ) } - if (isRechargeSuccessPage) { + if (isRechargeSuccessPage && isWebContentVisible) { successReturnBounds?.let { bounds -> CmbRechargeSuccessReturnOverlay( bounds = bounds, @@ -273,14 +1056,78 @@ fun CmbCardRecharge( } } - if (isLoading) { + if (!isWebContentVisible && nativeSuccess) { + CmbRechargeNativeSuccessPanel(onDone = latestRechargeSuccessExit.value) + } else if (!isWebContentVisible) { + CmbRechargeNativePanel( + data = nativeData, + errorMessage = errorMessage, + isSubmitting = isSubmitting, + onRetry = { + if (entryUrl == null) { + tokenRequestVersion += 1 + } else { + reloadEntry() + } + }, + onManagePaymentMethods = { + errorMessage = null + allowWebContentReveal = true + forceWebContent = true + }, + onSubmit = { amount, paymentMethodIndex -> + behaviorReporter.organic(AppActionId.SUBMIT_CMB_CARD_RECHARGE) + errorMessage = null + isSubmitting = true + allowWebContentReveal = true + forceWebContent = false + webView?.submitCmbRecharge( + amount = amount, + paymentMethodIndex = paymentMethodIndex, + onRejected = { message -> + isSubmitting = false + allowWebContentReveal = false + forceWebContent = false + errorMessage = message + } + ) + } + ) + } + + if (queryPasswordRequired) { + CmbRechargeQueryPasswordDialog( + onCancel = { + queryPasswordRequired = false + isSubmitting = false + isPasswordDispatching = false + allowWebContentReveal = false + webView?.cancelCmbRechargePassword() + }, + onConfirm = { password -> + queryPasswordRequired = false + isPasswordDispatching = true + webView?.submitCmbRechargePassword( + password = password, + onRejected = { message -> + isSubmitting = false + isPasswordDispatching = false + allowWebContentReveal = false + errorMessage = message + } + ) + } + ) + } + + if (isWebContentVisible && isLoading) { CircularProgressIndicator( modifier = Modifier.align(Alignment.Center), color = colorScheme.primary ) } - if (progress in 1..99) { + if (isWebContentVisible && progress in 1..99) { LinearProgressIndicator( progress = { progress / 100f }, modifier = Modifier @@ -289,7 +1136,7 @@ fun CmbCardRecharge( ) } - errorMessage?.let { message -> + if (isWebContentVisible) errorMessage?.let { message -> Column( modifier = Modifier .align(Alignment.Center) @@ -359,6 +1206,9 @@ private fun createCmbRechargeWebView( onProgressChanged: (Int) -> Unit, onSuccessPageChanged: (Boolean) -> Unit, onSuccessReturnBoundsChanged: (CmbRechargeNormalizedBounds?) -> Unit, + onNativeDataChanged: (CmbRechargeNativeData) -> Unit, + onPaymentUiStateChanged: (requiresPassword: Boolean, error: String) -> Unit, + onPageChanged: (String?) -> Unit, onMainFrameError: (String) -> Unit, onExternalLink: (String) -> Unit, onSubmitIntent: () -> Unit @@ -381,6 +1231,10 @@ private fun createCmbRechargeWebView( } android.webkit.CookieManager.getInstance().setAcceptCookie(true) addJavascriptInterface(CmbBehaviorBridge(this, onSubmitIntent), "AhuTongBehaviorBridge") + addJavascriptInterface( + CmbRechargeStateBridge(this, onNativeDataChanged, onPaymentUiStateChanged), + "AhuTongRechargeBridge" + ) val boundsLocator = CmbRechargeBoundsLocator(this, onSuccessReturnBoundsChanged) tag = CmbRechargeWebViewState(boundsLocator = boundsLocator) @@ -389,6 +1243,9 @@ private fun createCmbRechargeWebView( onProgressChanged(newProgress) if (newProgress >= 100) { onLoadingChanged(false) + if (view != null && isCmbRechargeNativeEntryUrl(view.url)) { + view.evaluateJavascript(CMB_NATIVE_STATE_BRIDGE_SCRIPT, null) + } } } } @@ -412,6 +1269,11 @@ private fun createCmbRechargeWebView( onExternalLink(targetUri.toString()) return true } + val upgradedCashierUrl = buildCmbHttpsCashierUrl(targetUri.toString()) + if (upgradedCashierUrl != null && upgradedCashierUrl != targetUri.toString()) { + view?.loadUrl(upgradedCashierUrl) + return true + } return if (isInternalCmbRechargeUrl(targetUri)) { false } else { @@ -424,27 +1286,47 @@ private fun createCmbRechargeWebView( onLoadingChanged(true) boundsLocator.clear() updateSuccessPage(url) + onPageChanged(url) super.onPageStarted(view, url, favicon) } override fun onPageFinished(view: WebView?, url: String?) { onLoadingChanged(false) updateSuccessPage(url) + onPageChanged(url) if (view != null) { applyCmbRechargePageStyle(view, url, pageStyleScript()) if (url?.let(Uri::parse)?.let(::isAuditedCmbSubmitPage) == true) { view.evaluateJavascript(CMB_SUBMIT_OBSERVER_SCRIPT, null) } + if (isCmbRechargeNativeEntryUrl(url)) { + view.evaluateJavascript(CMB_NATIVE_STATE_BRIDGE_SCRIPT, null) + view.evaluateJavascript(CMB_NATIVE_PAYMENT_UI_SCRIPT, null) + } boundsLocator.locate(url) } super.onPageFinished(view, url) } + override fun onPageCommitVisible(view: WebView?, url: String?) { + onPageChanged(url) + if (view != null && isCmbRechargeNativeEntryUrl(url)) { + view.evaluateJavascript(CMB_NATIVE_STATE_BRIDGE_SCRIPT, null) + view.evaluateJavascript(CMB_NATIVE_PAYMENT_UI_SCRIPT, null) + } + super.onPageCommitVisible(view, url) + } + override fun doUpdateVisitedHistory( view: WebView?, url: String?, isReload: Boolean ) { + onPageChanged(url) + if (view != null && isCmbRechargeNativeEntryUrl(url)) { + view.evaluateJavascript(CMB_NATIVE_STATE_BRIDGE_SCRIPT, null) + view.evaluateJavascript(CMB_NATIVE_PAYMENT_UI_SCRIPT, null) + } if (updateSuccessPage(url) && view != null) boundsLocator.locate(url) super.doUpdateVisitedHistory(view, url, isReload) } @@ -462,10 +1344,114 @@ private fun createCmbRechargeWebView( } super.onReceivedError(view, request, error) } + + override fun onReceivedHttpError( + view: WebView?, + request: WebResourceRequest?, + errorResponse: WebResourceResponse? + ) { + if (request?.isForMainFrame == true) { + val statusCode = errorResponse?.statusCode + val pageLocation = safeCmbPageLocation(request.url?.toString()) + if (statusCode == 412 && isCmbLoginRedirectUrl(request.url?.toString())) { + Log.w( + "CmbRechargeHttp", + "CMB login redirect returned HTTP 412; awaiting page retry" + ) + onLoadingChanged(true) + super.onReceivedHttpError(view, request, errorResponse) + return + } + + val upgradedCashierUrl = if (statusCode == 412) { + buildCmbHttpsCashierUrl(request.url?.toString()) + } else { + null + } + if (upgradedCashierUrl != null) { + Log.w( + "CmbRechargeHttp", + "Upgrading CMB cashier navigation to HTTPS after HTTP 412" + ) + view?.loadUrl(upgradedCashierUrl) + super.onReceivedHttpError(view, request, errorResponse) + return + } + + Log.w("CmbRechargeHttp", "main-frame HTTP $statusCode at $pageLocation") + onLoadingChanged(false) + boundsLocator.clear() + onSuccessPageChanged(false) + onMainFrameError( + if (statusCode != null) { + "页面加载失败(HTTP $statusCode,$pageLocation),请稍后重试" + } else { + "页面加载失败,请稍后重试" + } + ) + } + super.onReceivedHttpError(view, request, errorResponse) + } + } + } +} + +private data class CmbRechargeBridgePayload( + val studentNumber: String = "", + val balance: Double = 0.0, + val paymentMethods: List = emptyList() +) + +private data class CmbRechargeBridgePaymentMethod( + val pageIndex: Int = -1, + val name: String = "" +) + +private class CmbRechargeStateBridge( + private val webView: WebView, + private val onNativeDataChanged: (CmbRechargeNativeData) -> Unit, + private val onPaymentUiStateChanged: (Boolean, String) -> Unit +) { + private val gson = Gson() + + @JavascriptInterface + fun onRechargeState(payload: String) { + webView.post { + if (!isCmbRechargeNativeEntryUrl(webView.url)) return@post + val parsed = runCatching { + gson.fromJson(payload, CmbRechargeBridgePayload::class.java) + }.getOrNull() ?: return@post + val methods = parsed.paymentMethods + .filter { it.pageIndex >= 0 && it.name.isNotBlank() } + .distinctBy { it.pageIndex } + .map { CmbRechargePaymentMethod(pageIndex = it.pageIndex, name = it.name) } + onNativeDataChanged( + CmbRechargeNativeData( + studentNumber = parsed.studentNumber, + balance = normalizeCmbRechargeBalance(parsed.balance), + paymentMethods = methods + ) + ) + } + } + + @JavascriptInterface + fun onPaymentUiState(payload: String) { + webView.post { + if (!isCmbRechargeNativeEntryUrl(webView.url)) return@post + val parsed = runCatching { + gson.fromJson(payload, CmbPaymentUiPayload::class.java) + }.getOrNull() ?: return@post + onPaymentUiStateChanged(parsed.passwordRequired, parsed.error.orEmpty()) } } } +private data class CmbPaymentUiPayload( + val passwordRequired: Boolean = false, + val error: String? = null +) + private class CmbBehaviorBridge( private val webView: WebView, private val onSubmitIntent: () -> Unit @@ -489,10 +1475,183 @@ private class CmbBehaviorBridge( private companion object { const val NATIVE_SUBMIT_DEBOUNCE_MS = 1_000L } } +private fun WebView.submitCmbRecharge( + amount: String, + paymentMethodIndex: Int, + onRejected: (String) -> Unit +) { + val amountValue = amount.toDoubleOrNull() + if ( + !isCmbRechargeNativeEntryUrl(url) || + amountValue == null || + amountValue <= 0.0 || + amountValue > 1_000.0 || + paymentMethodIndex < 0 + ) { + onRejected("充值页面状态已变化,请重试") + return + } + val script = """ + (function(){ + var root = document.querySelector('#app'); + var queue = root && root.__vue__ ? [root.__vue__] : []; + var seen = []; + var component = null; + while (queue.length) { + var current = queue.shift(); + if (!current || seen.indexOf(current) >= 0) continue; + seen.push(current); + if (typeof current.rechargeOrders === 'function' && Array.isArray(current.payType)) { + component = current; + break; + } + var children = current[String.fromCharCode(36) + 'children']; + if (children) queue = queue.concat(children); + } + if (!component || !component.payType[$paymentMethodIndex]) return 'not-ready'; + component.tranAmt = $amountValue; + component.payTypeIndex = $paymentMethodIndex; + component.cardIndex = 0; + component.charge(); + return 'submitted'; + })(); + """.trimIndent() + evaluateJavascript(script) { result -> + if (result != "\"submitted\"") { + onRejected("充值页面尚未准备好,请稍后重试") + } + } +} + +private fun WebView.submitCmbRechargePassword( + password: String, + onRejected: (String) -> Unit +) { + if ( + !isCmbRechargeNativeEntryUrl(url) || + password.length != 6 || + !password.all(Char::isDigit) + ) { + onRejected("请输入 6 位校园卡查询密码") + return + } + val escapedPassword = password + .replace("\\", "\\\\") + .replace("'", "\\'") + val script = """ + (function(){ + function visible(node) { + if (!node) return false; + var style = window.getComputedStyle(node); + return style.display !== 'none' && + style.visibility !== 'hidden' && + node.getClientRects().length > 0; + } + var sheet = Array.from(document.querySelectorAll('.van-action-sheet')).find(visible); + if (!sheet || !(sheet.innerText || '').includes('查询密码')) return 'not-ready'; + var existingDots = Array.from( + sheet.querySelectorAll('.van-password-input__security i') + ).filter(visible).length; + if (existingDots !== 0) { + return 'password-not-empty'; + } + function currentSheet() { + return Array.from(document.querySelectorAll('.van-action-sheet')).find(visible); + } + function findCurrentKey(value) { + var current = currentSheet(); + return current && Array.from(current.querySelectorAll('.keyboard td')).find(function(node) { + return (node.innerText || '').trim() === value; + }); + } + function visiblePasswordDots() { + var current = currentSheet(); + return current + ? Array.from(current.querySelectorAll('.van-password-input__security i')) + .filter(visible).length + : 0; + } + function fail(message) { + window.AhuTongRechargeBridge.onPaymentUiState(JSON.stringify({ + passwordRequired: false, + error: message + })); + } + var password = '$escapedPassword'; + if (Array.from(password).some(function(value) { return !findCurrentKey(value); })) { + return 'key-not-found'; + } + if (!findCurrentKey('确认')) return 'confirm-not-found'; + function pressAt(index) { + if (index >= password.length) { + var confirm = findCurrentKey('确认'); + if (confirm && visiblePasswordDots() === password.length) { + confirm.click(); + } else { + fail('查询密码键盘状态异常,请重试'); + } + return; + } + var key = findCurrentKey(password[index]); + if (!key) { + fail('查询密码键盘已变化,请重试'); + return; + } + key.click(); + var attempts = 0; + function waitForDot() { + if (visiblePasswordDots() >= index + 1) { + window.setTimeout(function() { pressAt(index + 1); }, 120); + } else if (attempts++ < 15) { + window.setTimeout(waitForDot, 50); + } else { + fail('查询密码键盘响应超时,请重试'); + } + } + window.setTimeout(waitForDot, 50); + } + pressAt(0); + return 'scheduled'; + })(); + """.trimIndent() + evaluateJavascript(script) { result -> + if (result != "\"scheduled\"") { + onRejected("查询密码键盘尚未准备好,请重试") + } + } +} + +private fun WebView.cancelCmbRechargePassword() { + if (!isCmbRechargeNativeEntryUrl(url)) return + evaluateJavascript( + """ + (function(){ + var sheet = Array.from(document.querySelectorAll('.van-action-sheet')) + .find(function(node){ return (node.innerText || '').includes('查询密码'); }); + var cancel = sheet && sheet.querySelector('.van-action-sheet__cancel'); + if (cancel) { + cancel.click(); + } else { + var overlays = Array.from(document.querySelectorAll('.van-overlay')); + var overlay = overlays.find(function(node) { + return window.getComputedStyle(node).display !== 'none'; + }); + if (overlay) overlay.click(); + } + })(); + """.trimIndent(), + null + ) +} + private class CmbRechargeWebViewState( val boundsLocator: CmbRechargeBoundsLocator, var requestVersion: Int = -1 -) +) { + fun dispose() { + boundsLocator.dispose() + } +} private val WebView.cmbRechargeState: CmbRechargeWebViewState? get() = tag as? CmbRechargeWebViewState @@ -638,11 +1797,16 @@ internal fun isCmbRechargeSuccessUrl(url: String?): Boolean { 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) && + host == "epay92.ahu.edu.cn" && path == "/cashier-mobile/chargeresult" } +internal fun shouldConfirmCmbRechargeSuccess( + url: String?, + verifiedReturnBounds: CmbRechargeNormalizedBounds? +): Boolean = verifiedReturnBounds != null && isCmbRechargeSuccessUrl(url) + internal fun isCmbRechargeStyleTarget(url: String?): Boolean { if (url.isNullOrBlank()) return false val uri = runCatching { URI(url) }.getOrNull() ?: return false @@ -655,6 +1819,64 @@ internal fun isCmbRechargeStyleTarget(url: String?): Boolean { } } +internal fun isCmbRechargeNativeEntryUrl(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" && + uri.port in setOf(-1, 443) && + host == "epay92.ahu.edu.cn" && + path == "/cashier-mobile/charge" +} + +internal fun isCmbRechargeInsecureEntryUrl(url: String?): Boolean { + if (url.isNullOrBlank()) return false + val uri = runCatching { URI(url) }.getOrNull() ?: return false + return uri.scheme.orEmpty().equals("http", ignoreCase = true) && + uri.port in setOf(-1, 80) && + uri.host.orEmpty().equals("epay92.ahu.edu.cn", ignoreCase = true) && + uri.path.orEmpty().trimEnd('/').equals( + "/cashier-mobile/charge", + ignoreCase = true + ) +} + +internal fun isCmbRechargeHiddenFlowUrl(url: String?): Boolean { + if (isCmbRechargeNativeEntryUrl(url)) return true + 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 == "ycard.ahu.edu.cn" && + uri.port in setOf(-1, 443) && + path == "/berserker-base/redirect" +} + +internal fun shouldRevealCmbRechargeWebContent( + url: String?, + revealAllowed: Boolean +): Boolean = revealAllowed && + !url.isNullOrBlank() && + !isCmbRechargeHiddenFlowUrl(url) && + !isCmbRechargeSuccessUrl(url) + +private fun WebView.updateCmbRechargeWebViewVisibility(isVisible: Boolean) { + visibility = if (isVisible) View.VISIBLE else View.INVISIBLE + isEnabled = isVisible + importantForAccessibility = if (isVisible) { + View.IMPORTANT_FOR_ACCESSIBILITY_AUTO + } else { + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } +} + +internal fun normalizeCmbRechargeBalance(balanceInCents: Double): Double = + if (balanceInCents.isFinite()) balanceInCents / 100.0 else 0.0 + private fun buildCmbRechargeEntryUrl(token: String): String { return Uri.Builder() .scheme("https") @@ -670,6 +1892,51 @@ private fun buildCmbRechargeEntryUrl(token: String): String { .toString() } +internal fun isCmbLoginRedirectUrl(url: String?): Boolean { + val uri = url?.let { runCatching { URI(it) }.getOrNull() } ?: return false + return uri.scheme.equals("https", ignoreCase = true) && + uri.host.equals("epay92.ahu.edu.cn", ignoreCase = true) && + uri.port in setOf(-1, 443) && + uri.path.orEmpty().trimEnd('/').equals( + "/member/login/redirect", + ignoreCase = true + ) +} + +internal fun buildCmbHttpsCashierUrl(url: String?): String? { + val uri = url?.let { runCatching { URI(it) }.getOrNull() } ?: return null + val scheme = uri.scheme.orEmpty().lowercase() + val trustedPort = scheme == "http" && uri.port in setOf(-1, 80) + if ( + !trustedPort || + !uri.host.equals("epay92.ahu.edu.cn", ignoreCase = true) || + !uri.path.orEmpty().trimEnd('/').equals( + "/cashier-mobile/cashier", + ignoreCase = true + ) + ) { + return null + } + + return URI( + "https", + uri.userInfo, + uri.host, + -1, + uri.path, + uri.query, + uri.fragment + ).toString() +} + +private fun safeCmbPageLocation(url: String?): String { + val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return "未知页面" + val scheme = uri.scheme.orEmpty().lowercase() + val host = uri.host.orEmpty().lowercase() + val path = uri.encodedPath.orEmpty().ifBlank { "/" } + return "$scheme://$host$path" +} + private fun isInternalCmbRechargeUrl(url: Uri): Boolean { val host = url.host.orEmpty().lowercase() return host == "ahu.edu.cn" || host.endsWith(".ahu.edu.cn") diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbRechargeNativePanel.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbRechargeNativePanel.kt new file mode 100644 index 00000000..fc4707e3 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbRechargeNativePanel.kt @@ -0,0 +1,508 @@ +package com.ahu.ahutong.ui.screen.main + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +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.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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +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.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.ahu.ahutong.ui.component.SecurePaymentPasswordDialog +import java.util.Locale + +internal data class CmbRechargeNativeData( + val studentNumber: String, + val balance: Double, + val paymentMethods: List +) + +internal data class CmbRechargePaymentMethod( + val pageIndex: Int, + val name: String +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun CmbRechargeNativePanel( + data: CmbRechargeNativeData?, + errorMessage: String?, + isSubmitting: Boolean, + onRetry: () -> Unit, + onManagePaymentMethods: () -> Unit, + onSubmit: (amount: String, paymentMethodIndex: Int) -> Unit +) { + var amount by remember { mutableStateOf("") } + var selectedPaymentMethodIndex by remember { mutableIntStateOf(-1) } + var paymentMenuExpanded by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + + LaunchedEffect(data?.paymentMethods) { + val methods = data?.paymentMethods.orEmpty() + if (methods.none { it.pageIndex == selectedPaymentMethodIndex }) { + selectedPaymentMethodIndex = methods.firstOrNull()?.pageIndex ?: -1 + } + } + + val amountValue = amount.toDoubleOrNull() + val amountError = when { + amount.isBlank() -> null + amountValue == null || amountValue <= 0.0 -> "请输入有效的充值金额" + amountValue > CMB_RECHARGE_MAX_AMOUNT -> "单次充值金额不能超过 1000 元" + else -> null + } + val selectedMethod = data?.paymentMethods + ?.firstOrNull { it.pageIndex == selectedPaymentMethodIndex } + val canSubmit = data != null && + selectedMethod != null && + amountValue != null && + amountValue > 0.0 && + amountValue <= CMB_RECHARGE_MAX_AMOUNT && + !isSubmitting + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + when { + data == null && errorMessage != null -> NativeRechargeLoadState( + title = "充值信息加载失败", + message = errorMessage, + onRetry = onRetry + ) + + data == null -> NativeRechargeLoadState( + title = "正在加载充值信息", + message = "正在安全连接校园卡充值服务,请稍候。" + ) + + else -> { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentPadding = PaddingValues( + start = 16.dp, + top = 16.dp, + end = 16.dp, + bottom = 12.dp + ), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (errorMessage != null) { + item { + NativeRechargeMessageCard( + title = "本次充值未完成", + message = errorMessage, + actionText = "重新加载", + onAction = onRetry + ) + } + } + + item { NativeRechargeAccountCard(data = data) } + + item { + NativeRechargeSection(title = "充值金额") { + OutlinedTextField( + value = amount, + onValueChange = { value -> + if (value.matches(Regex("^\\d{0,4}(\\.\\d{0,2})?$"))) { + amount = value + } + }, + modifier = Modifier.fillMaxWidth(), + label = { Text("充值金额") }, + prefix = { Text("¥ ") }, + placeholder = { Text("请输入金额") }, + supportingText = amountError?.let { message -> { Text(message) } }, + isError = amountError != null, + singleLine = true, + enabled = !isSubmitting, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { focusManager.clearFocus() } + ) + ) + + CMB_RECHARGE_PRESETS.chunked(2).forEach { presets -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + presets.forEach { preset -> + OutlinedButton( + onClick = { + amount = preset + focusManager.clearFocus() + }, + modifier = Modifier + .weight(1f) + .height(48.dp), + enabled = !isSubmitting, + contentPadding = PaddingValues(horizontal = 8.dp) + ) { + Text("¥$preset") + } + } + } + } + } + } + + item { + NativeRechargeSection(title = "支付方式") { + if (data.paymentMethods.isEmpty()) { + Text( + text = "尚未绑定可用的免密支付方式,请先前往学校支付页面完成绑定。", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + TextButton( + onClick = onManagePaymentMethods, + enabled = !isSubmitting + ) { + Text("管理免密支付方式") + } + } else { + ExposedDropdownMenuBox( + expanded = paymentMenuExpanded, + onExpandedChange = { + if (!isSubmitting) paymentMenuExpanded = !paymentMenuExpanded + }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = selectedMethod?.name.orEmpty(), + onValueChange = {}, + modifier = Modifier + .fillMaxWidth() + .menuAnchor( + type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, + enabled = !isSubmitting + ), + readOnly = true, + enabled = !isSubmitting, + label = { Text("扣款方式") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = paymentMenuExpanded + ) + }, + singleLine = true + ) + ExposedDropdownMenu( + expanded = paymentMenuExpanded, + onDismissRequest = { paymentMenuExpanded = false } + ) { + data.paymentMethods.forEach { method -> + DropdownMenuItem( + text = { Text(method.name) }, + onClick = { + selectedPaymentMethodIndex = method.pageIndex + paymentMenuExpanded = false + } + ) + } + } + } + TextButton( + onClick = onManagePaymentMethods, + enabled = !isSubmitting + ) { + Text("管理支付方式") + } + } + } + } + + item { + Text( + text = "充值金额将先进入过渡余额,刷卡后转入校园卡。银行卡授权与验证码只在官方页面完成;如需校园卡查询密码,本页会将其安全转交当前校方充值页面。", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + } + } + + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 3.dp + ) { + Button( + onClick = { + focusManager.clearFocus() + onSubmit(amount, selectedPaymentMethodIndex) + }, + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .imePadding() + .padding(horizontal = 16.dp, vertical = 12.dp) + .height(56.dp), + enabled = canSubmit + ) { + if (isSubmitting) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Text("确认充值") + } + } + } + } + } + } +} + +@Composable +private fun ColumnScope.NativeRechargeLoadState( + title: String, + message: String, + onRetry: (() -> Unit)? = null +) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (onRetry == null) { + CircularProgressIndicator(modifier = Modifier.size(36.dp)) + } + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + Text( + text = message, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + onRetry?.let { retry -> + Button(onClick = retry) { Text("重试") } + } + } + } +} + +@Composable +private fun NativeRechargeAccountCard(data: CmbRechargeNativeData) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 1.dp + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "校园卡当前余额", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelLarge + ) + Text( + text = String.format(Locale.CHINA, "¥ %.2f", data.balance), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.headlineMedium + ) + NativeRechargeInfoRow( + label = "学工号", + value = data.studentNumber.ifBlank { "未获取到" } + ) + } + } +} + +@Composable +private fun NativeRechargeSection( + title: String, + content: @Composable ColumnScope.() -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + content() + } + } +} + +@Composable +private fun NativeRechargeInfoRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + text = value, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun NativeRechargeMessageCard( + title: String, + message: String, + actionText: String, + onAction: () -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.errorContainer + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = title, + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.titleMedium + ) + Text(message, color = MaterialTheme.colorScheme.onErrorContainer) + TextButton(onClick = onAction) { Text(actionText) } + } + } +} + +private const val CMB_RECHARGE_MAX_AMOUNT = 1_000.0 +private val CMB_RECHARGE_PRESETS = listOf("50", "100", "200", "500") + +@Composable +internal fun CmbRechargeQueryPasswordDialog( + onCancel: () -> Unit, + onConfirm: (String) -> Unit +) { + var password by remember { mutableStateOf("") } + + SecurePaymentPasswordDialog( + password = password, + onPasswordChange = { password = it }, + title = "输入校园卡查询密码", + onDismissRequest = onCancel, + onConfirm = onConfirm + ) +} + +@Composable +internal fun CmbRechargeNativeSuccessPanel( + onDone: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .navigationBarsPadding() + .padding(24.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = "充值成功", + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + Text( + text = "订单已由招商银行免密支付完成,余额将在刷卡后转入校园卡。", + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + Button( + onClick = onDone, + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + Text("返回校园卡") + } + } +} 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..f30163cf 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 @@ -65,6 +65,7 @@ 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.ElectricityDepositViewModel +import com.ahu.ahutong.ui.component.SecurePaymentPasswordDialog import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.withNight @@ -89,7 +90,7 @@ fun ElectricityDeposit( LaunchedEffect(payState.value) { when (payState.value) { is PayState.Succeeded, is PayState.Failed -> { - delay(1000) + delay(PAYMENT_RESULT_DISPLAY_DURATION_MS) viewModel.resetPaymentState() } @@ -603,61 +604,26 @@ fun ElectricityDeposit( } } 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 - ) - } - } + SecurePaymentPasswordDialog( + password = password, + onPasswordChange = { + password = it + errorMsg = null }, - 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) - } + title = "请输入校园卡密码", + errorMessage = errorMsg, + onDismissRequest = { + showDialog = false + password = "" + errorMsg = null }, - dismissButton = { - TextButton(onClick = { + onConfirm = { confirmedPassword -> + if (confirmedPassword.length == 6) { showDialog = false - password = "" - errorMsg = null - }) { - Text("取消", color = 10.n1 withNight 90.n1) + behaviorReporter.organic(AppActionId.CONFIRM_ELECTRICITY_PAYMENT) + viewModel.pay(amount, confirmedPassword) + } else { + errorMsg = "密码必须是6位数字" } } ) @@ -692,3 +658,5 @@ fun ElectricityDeposit( } } } + +private const val PAYMENT_RESULT_DISPLAY_DURATION_MS = 3_000L 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..c6fe279e 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 @@ -56,6 +56,7 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.ahu.ahutong.data.crawler.PayState 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 @@ -88,7 +89,7 @@ fun NetworkRecharge( LaunchedEffect(payState) { when (payState) { is PayState.Succeeded, is PayState.Failed -> { - delay(1200) + delay(PAYMENT_RESULT_DISPLAY_DURATION_MS) viewModel.resetPayState() } @@ -172,73 +173,36 @@ 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( 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..06ed8839 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 @@ -232,12 +232,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 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..3a46ddd1 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 @@ -53,8 +53,8 @@ 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.SettingsSelectRow import com.ahu.ahutong.ui.components.SettingsPageHeader import com.ahu.ahutong.ui.components.SettingsSection import com.ahu.ahutong.ui.components.SettingsToggleRow @@ -78,12 +78,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 useBuiltInSecurePasswordKeyboard by + viewModel.useBuiltInSecurePasswordKeyboard.collectAsState() val themeColor by viewModel.themeColor.collectAsState() val courseReminderEnabled by viewModel.courseReminderEnabled.collectAsState() val courseReminderLiveCountdownEnabled by @@ -173,9 +174,8 @@ fun Preferences(onBack: () -> Unit = {}) { onHorizontalDragActiveChange = onToggleHorizontalDragActiveChange ) } - SettingsDialogSelectRow( + SettingsSelectRow( title = "本地记录保留期", - dialogTitle = "选择本地记录保留期", selected = behaviorRetentionDays, choices = listOf( SettingsChoice(7, "7 天"), @@ -237,10 +237,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 +310,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, "跟随系统"), 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..0788a113 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,7 +2,6 @@ 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.model.AppThemeMode import com.ahu.ahutong.personalization.runtime.BehaviorPredictionRuntime @@ -36,14 +35,15 @@ 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 _useLiquidGlass = MutableStateFlow(true) + val useLiquidGlass: StateFlow = _useLiquidGlass.asStateFlow() + + private val _useBuiltInSecurePasswordKeyboard = MutableStateFlow(true) + val useBuiltInSecurePasswordKeyboard: StateFlow = + _useBuiltInSecurePasswordKeyboard.asStateFlow() private val _themeColor = MutableStateFlow(null) val themeColor: StateFlow = _themeColor.asStateFlow() @@ -86,11 +86,16 @@ class PreferencesViewModel @Inject constructor( _isShowAllCourse.value = it } } - viewModelScope.launch { - preferencesManager.useLiquidGlass.collect { - _useLiquidGlass.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 @@ -172,8 +177,8 @@ class PreferencesViewModel @Inject constructor( val oldValue = _useLiquidGlass.value preferencesManager.setUseLiquidGlass(value) behaviorRuntime.recordCommittedMutation(MutationId.LIQUID_GLASS_CHANGED, oldValue, value) - } - } + } + } fun setCourseReminderEnabled(value: Boolean) { viewModelScope.launch { @@ -183,24 +188,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) } } 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..55226e3b 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 @@ -8,6 +8,8 @@ import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme 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,6 +18,7 @@ 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 @@ -36,6 +39,7 @@ fun AHUTheme(content: @Composable () -> Unit) { val themeMode by preferencesViewModel.appThemeMode.collectAsState() val useLiquidGlass by preferencesViewModel.useLiquidGlass.collectAsState() val isDarkTheme = themeMode.resolve(isSystemInDarkTheme()) + val context = LocalContext.current val configuration = LocalConfiguration.current val themeConfiguration = remember(configuration, isDarkTheme) { Configuration(configuration).apply { @@ -77,7 +81,59 @@ fun AHUTheme(content: @Composable () -> Unit) { LocalConfiguration provides themeConfiguration, LocalTonalPalettes provides tonalPalettes ) { - MaterialTheme(colorScheme = dynamicColorScheme(isLight = !isDarkTheme)) { + val colorScheme = if ( + customKeyColor == null && 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 + ) + } + } + MaterialTheme(colorScheme = colorScheme) { CompositionLocalProvider( LocalContentColor provides if (isDarkTheme) 100.n1 else 0.n1, LocalIsLiquidGlassEnabled provides useLiquidGlass, 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 index 443544f9..c005188d 100644 --- 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 @@ -2,6 +2,7 @@ package com.ahu.ahutong.ui.screen.main import kotlin.test.Test import kotlin.test.assertContains +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -150,6 +151,11 @@ class CmbRechargePageStyleTest { "https://epay92.ahu.edu.cn/cashier-mobile/chargeResult/?order=1" ) ) + assertFalse( + isCmbRechargeSuccessUrl( + "http://epay92.ahu.edu.cn/cashier-mobile/chargeResult" + ) + ) assertFalse( isCmbRechargeSuccessUrl( "https://epay92.ahu.edu.cn/cashier-mobile/charge" @@ -162,7 +168,7 @@ class CmbRechargePageStyleTest { ) assertFalse( isCmbRechargeSuccessUrl( - "http://epay92.ahu.edu.cn/cashier-mobile/chargeResult" + "http://epay92.ahu.edu.cn:8080/cashier-mobile/chargeResult" ) ) assertFalse( @@ -183,11 +189,279 @@ class CmbRechargePageStyleTest { } + @Test + fun nativeEntryUrlIsStrictlyScoped() { + assertFalse( + isCmbRechargeNativeEntryUrl( + "http://epay92.ahu.edu.cn/cashier-mobile/charge?disable=1" + ) + ) + assertTrue( + isCmbRechargeInsecureEntryUrl( + "http://epay92.ahu.edu.cn/cashier-mobile/charge?disable=1" + ) + ) + assertFalse( + isCmbRechargeInsecureEntryUrl( + "http://epay92.ahu.edu.cn.evil.example/cashier-mobile/charge" + ) + ) + assertTrue( + isCmbRechargeNativeEntryUrl( + "https://epay92.ahu.edu.cn/cashier-mobile/charge/" + ) + ) + assertFalse( + isCmbRechargeNativeEntryUrl( + "https://epay92.ahu.edu.cn.evil.example/cashier-mobile/charge" + ) + ) + assertFalse( + isCmbRechargeNativeEntryUrl( + "https://epay92.ahu.edu.cn/cashier-mobile/chargeResult" + ) + ) + assertFalse( + isCmbRechargeNativeEntryUrl( + "https://epay92.ahu.edu.cn:444/cashier-mobile/charge" + ) + ) + } + + @Test + fun hiddenFlowUrlIncludesOnlyTheTrustedBootstrapAndNativeEntry() { + assertTrue( + isCmbRechargeHiddenFlowUrl( + "https://ycard.ahu.edu.cn/berserker-base/redirect?appId=253" + ) + ) + assertTrue( + isCmbRechargeHiddenFlowUrl( + "https://epay92.ahu.edu.cn/cashier-mobile/charge?disable=1" + ) + ) + assertFalse( + isCmbRechargeHiddenFlowUrl( + "https://ycard.ahu.edu.cn/berserker-base/redirect/other" + ) + ) + assertFalse( + isCmbRechargeHiddenFlowUrl( + "https://ycard.ahu.edu.cn.evil.example/berserker-base/redirect" + ) + ) + } + + @Test + fun webContentIsRevealedOnlyAfterAnExplicitNativeAction() { + val bootstrapUrl = + "https://ycard.ahu.edu.cn/berserker-base/redirect?appId=253" + val nativeEntryUrl = + "https://epay92.ahu.edu.cn/cashier-mobile/charge?disable=1" + val officialPaymentUrl = + "https://epay92.ahu.edu.cn/cashier-mobile/pay" + val successUrl = + "https://epay92.ahu.edu.cn/cashier-mobile/chargeResult" + + assertFalse(shouldRevealCmbRechargeWebContent(null, revealAllowed = true)) + assertFalse(shouldRevealCmbRechargeWebContent(bootstrapUrl, revealAllowed = true)) + assertFalse(shouldRevealCmbRechargeWebContent(nativeEntryUrl, revealAllowed = true)) + assertFalse(shouldRevealCmbRechargeWebContent(officialPaymentUrl, revealAllowed = false)) + assertTrue(shouldRevealCmbRechargeWebContent(officialPaymentUrl, revealAllowed = true)) + assertFalse(shouldRevealCmbRechargeWebContent(successUrl, revealAllowed = true)) + } + + @Test + fun nativeBalanceConvertsServerCentsToYuan() { + assertEquals(7.79, normalizeCmbRechargeBalance(779.0), 0.0001) + assertEquals(0.0, normalizeCmbRechargeBalance(Double.NaN), 0.0001) + } + + @Test + fun preloadedSessionExpiresAtThreeMinutes() { + val readyAt = 10_000L + + assertTrue(isCmbRechargeSessionFresh(readyAt, readyAt)) + assertTrue( + isCmbRechargeSessionFresh( + readyAt, + readyAt + CMB_RECHARGE_PRELOAD_VALIDITY_MS - 1L + ) + ) + assertFalse( + isCmbRechargeSessionFresh( + readyAt, + readyAt + CMB_RECHARGE_PRELOAD_VALIDITY_MS + ) + ) + assertFalse(isCmbRechargeSessionFresh(0L, readyAt)) + assertFalse(isCmbRechargeSessionFresh(readyAt, readyAt - 1L)) + } + + @Test + fun rechargeCannotDispatchBeforePasswordIsProvided() { + assertFalse( + canDispatchCmbRecharge( + amount = "100", + password = null, + hasFreshSession = true + ) + ) + assertFalse( + canDispatchCmbRecharge( + amount = "100", + password = "", + hasFreshSession = true + ) + ) + assertFalse( + canDispatchCmbRecharge( + amount = "100", + password = "123456", + hasFreshSession = false + ) + ) + assertTrue( + canDispatchCmbRecharge( + amount = "100", + password = "123456", + hasFreshSession = true + ) + ) + + assertTrue( + canDispatchCmbPassword( + password = "123456", + dispatchInProgress = false, + hasWebView = true + ) + ) + assertFalse( + canDispatchCmbPassword( + password = null, + dispatchInProgress = false, + hasWebView = true + ) + ) + assertFalse( + canDispatchCmbPassword( + password = "123456", + dispatchInProgress = true, + hasWebView = true + ) + ) + } + + @Test + fun expiredSessionRecoveryRequiresCompleteSubmissionAndRunsOnlyOnce() { + assertTrue(isCmbSessionExpiredMessage("登录已失效,请重新登录")) + assertTrue(isCmbSessionExpiredMessage("当前会话已过期")) + assertFalse(isCmbSessionExpiredMessage("查询密码错误")) + + assertTrue( + shouldRecoverCmbSession( + message = "登录失效", + recoveryAttempted = false, + amount = "100", + password = "123456" + ) + ) + assertFalse( + shouldRecoverCmbSession( + message = "登录失效", + recoveryAttempted = true, + amount = "100", + password = "123456" + ) + ) + assertFalse( + shouldRecoverCmbSession( + message = "登录失效", + recoveryAttempted = false, + amount = "100", + password = null + ) + ) + assertFalse( + shouldRecoverCmbSession( + message = "查询密码错误", + recoveryAttempted = false, + amount = "100", + password = "123456" + ) + ) + } + + @Test + fun loginRedirectAndHttpsUpgradeAreStrictlyScoped() { + assertTrue( + isCmbLoginRedirectUrl( + "https://epay92.ahu.edu.cn/member/login/redirect?ticket=hidden" + ) + ) + assertFalse( + isCmbLoginRedirectUrl( + "https://epay92.ahu.edu.cn/cashier-mobile/charge" + ) + ) + assertFalse( + isCmbLoginRedirectUrl( + "https://epay92.ahu.edu.cn.evil.example/member/login/redirect" + ) + ) + assertFalse( + isCmbLoginRedirectUrl( + "http://epay92.ahu.edu.cn/member/login/redirect" + ) + ) + + assertEquals( + "https://epay92.ahu.edu.cn/cashier-mobile/cashier", + buildCmbHttpsCashierUrl( + "http://epay92.ahu.edu.cn/cashier-mobile/cashier" + ) + ) + assertEquals( + "https://epay92.ahu.edu.cn/cashier-mobile/cashier?ticket=hidden&embed=true", + buildCmbHttpsCashierUrl( + "http://epay92.ahu.edu.cn/cashier-mobile/cashier?ticket=hidden&embed=true" + ) + ) + assertNull( + buildCmbHttpsCashierUrl( + "https://epay92.ahu.edu.cn/cashier-mobile/cashier" + ) + ) + assertNull( + buildCmbHttpsCashierUrl( + "http://epay92.ahu.edu.cn.evil.example/cashier-mobile/cashier" + ) + ) + } + @Test fun normalizedOverlayBoundsAreParsedAndValidated() { val bounds = assertNotNull( parseCmbRechargeNormalizedBounds("[0.05,0.72,0.90,0.08]") ) + assertTrue( + shouldConfirmCmbRechargeSuccess( + "https://epay92.ahu.edu.cn/cashier-mobile/chargeResult", + bounds + ) + ) + assertFalse( + shouldConfirmCmbRechargeSuccess( + "https://epay92.ahu.edu.cn/cashier-mobile/chargeResult", + null + ) + ) + assertFalse( + shouldConfirmCmbRechargeSuccess( + "http://epay92.ahu.edu.cn/cashier-mobile/chargeResult", + bounds + ) + ) assertTrue(bounds.left in 0.049f..0.051f) assertTrue(bounds.top in 0.719f..0.721f) assertTrue(bounds.width in 0.899f..0.901f) From 130439a1ff220a4e3b0af00b96935eea7fe97ae0 Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Mon, 31 Aug 2026 17:20:16 +0800 Subject: [PATCH 05/29] feat(ui): unify themes and harden app flows --- .github/workflows/ci.yaml | 33 +- app/build.gradle.kts | 17 +- app/proguard-rules.pro | 51 +- .../DebugDiagnosticsContribution.kt | 34 +- .../ahu/ahutong/ui/screen/settings/Debug.kt | 26 +- app/src/main/AndroidManifest.xml | 3 +- .../java/com/ahu/ahutong/AHUApplication.java | 7 +- .../main/java/com/ahu/ahutong/MainActivity.kt | 42 +- .../appwidget/AdaptiveTestWidgetProvider.kt | 38 +- .../ahutong/appwidget/WidgetColorEngine.kt | 2 +- .../appwidget/WidgetUpdateScheduler.kt | 9 +- .../com/ahu/ahutong/data/AHURepository.kt | 257 +- .../com/ahu/ahutong/data/AHUResponse.java | 8 +- .../ahu/ahutong/data/EvaluationRepository.kt | 87 +- .../ahutong/data/crawler/CrawlerDataSource.kt | 43 +- .../ahu/ahutong/data/crawler/SdkDataSource.kt | 52 +- .../data/crawler/api/adwmh/AdwmhApi.kt | 49 +- .../data/crawler/api/jwxt/EvaluationApi.kt | 3 +- .../ahutong/data/crawler/api/jwxt/JwxtApi.kt | 34 +- .../data/crawler/api/ycard/YcardApi.kt | 89 +- .../data/crawler/manager/CookieManager.kt | 26 +- .../manager/EncryptedCookiePersistor.kt | 49 + .../data/crawler/manager/TokenManager.kt | 199 +- .../data/crawler/net/AutoLoginInterceptor.kt | 29 +- .../crawler/net/SessionRefreshCoordinator.kt | 68 + .../data/crawler/net/TokenAuthenticator.kt | 135 +- .../java/com/ahu/ahutong/data/dao/AHUCache.kt | 374 +- .../ahutong/data/dao/PreferencesManager.kt | 72 +- .../ahutong/data/mock_server/MockServer.kt | 46 - .../com/ahu/ahutong/data/model/AppUiTheme.kt | 13 + .../ahutong/data/model/CardRechargeBank.kt | 12 + .../model/ElectricityDepositHistoryItem.kt | 4 +- .../ahutong/data/model/EvaluationModels.kt | 2 +- .../data/repository/RepositoryManager.kt | 71 +- .../ahutong/data/security/SecureStorage.kt | 113 + .../com/ahu/ahutong/data/server/AhuTong.kt | 4 + .../ahu/ahutong/data/weather/WeatherApi.kt | 8 +- .../notification/CourseLiveUpdateHelper.kt | 12 + .../CourseReminderBootReceiver.kt | 5 +- .../notification/CourseReminderCapability.kt | 7 +- .../notification/CourseReminderNotifier.kt | 10 +- .../notification/CourseReminderReceiver.kt | 49 +- .../notification/CourseReminderScheduler.kt | 32 +- .../personalization/action/AppAction.kt | 7 +- .../bootstrap/BootstrapTrainingModels.kt | 8 +- .../journey/JourneyOnDeviceTrainer.kt | 23 +- .../journey/JourneyPredictionEngine.kt | 53 +- .../runtime/PredictionRuntime.kt | 14 +- .../main/java/com/ahu/ahutong/sdk/RustSDK.kt | 96 +- .../component/SecurePaymentPasswordDialog.kt | 431 ++ .../ahutong/ui/components/AppComponents.kt | 1977 ++++++ .../ahutong/ui/components/LiquidBottomTab.kt | 6 +- .../ahutong/ui/components/LiquidBottomTabs.kt | 71 +- .../ahu/ahutong/ui/components/LiquidButton.kt | 139 +- .../ui/components/LiquidGlassSurface.kt | 212 + .../ahu/ahutong/ui/components/LiquidSlider.kt | 105 +- .../ahu/ahutong/ui/components/LiquidToggle.kt | 87 +- .../components/LocalIsLiquidGlassEnabled.kt | 5 +- .../ui/components/SettingsComponents.kt | 629 +- .../ahu/ahutong/ui/screen/ApkUpdateDialog.kt | 24 +- .../com/ahu/ahutong/ui/screen/BottomNavBar.kt | 69 +- .../ahu/ahutong/ui/screen/HotUpdateDialog.kt | 16 +- .../java/com/ahu/ahutong/ui/screen/Main.kt | 394 +- .../com/ahu/ahutong/ui/screen/Settings.kt | 90 +- .../java/com/ahu/ahutong/ui/screen/Setup.kt | 4 +- .../java/com/ahu/ahutong/ui/screen/Splash.kt | 29 +- .../ahutong/ui/screen/main/BathroomDeposit.kt | 868 +-- .../ui/screen/main/CardBalanceDeposit.kt | 669 +- .../ahutong/ui/screen/main/CmbCardRecharge.kt | 1362 ++++- .../ui/screen/main/CmbRechargeNativePanel.kt | 474 ++ .../ui/screen/main/ElectricityDeposit.kt | 915 +-- .../ahu/ahutong/ui/screen/main/Evaluation.kt | 550 +- .../com/ahu/ahutong/ui/screen/main/Exam.kt | 144 +- .../ahutong/ui/screen/main/FreeClassroom.kt | 679 ++- .../ui/screen/main/FreeClassroomDatePicker.kt | 15 +- .../com/ahu/ahutong/ui/screen/main/Grade.kt | 276 +- .../com/ahu/ahutong/ui/screen/main/Home.kt | 176 +- .../ahu/ahutong/ui/screen/main/LostFound.kt | 731 +-- .../ahutong/ui/screen/main/NetworkRecharge.kt | 351 +- .../ahu/ahutong/ui/screen/main/PhoneBook.kt | 249 +- .../ahu/ahutong/ui/screen/main/Repository.kt | 133 +- .../ui/screen/main/RepositoryDownloads.kt | 102 +- .../ui/screen/main/RepositorySettings.kt | 37 +- .../ahu/ahutong/ui/screen/main/Schedule.kt | 359 +- .../ahutong/ui/screen/main/SchoolCalendar.kt | 22 +- .../com/ahu/ahutong/ui/screen/main/Tools.kt | 64 +- .../com/ahu/ahutong/ui/screen/main/Weather.kt | 255 +- .../ahutong/ui/screen/main/home/AtAGlance.kt | 12 +- .../ui/screen/main/home/BathroomOpening.kt | 12 +- .../ahutong/ui/screen/main/home/CampusCard.kt | 28 +- .../ui/screen/main/home/ElectricityPayment.kt | 12 +- .../ui/screen/main/home/HomeWeatherWidget.kt | 48 +- .../ui/screen/main/home/HomeWidgetEditor.kt | 28 +- .../ui/screen/main/home/TodayCourseList.kt | 23 +- .../ui/screen/main/schedule/CourseCard.kt | 40 +- .../main/schedule/CourseDetailDialog.kt | 11 +- .../ui/screen/settings/Contributors.kt | 158 +- .../ahu/ahutong/ui/screen/settings/License.kt | 126 +- .../ahutong/ui/screen/settings/Preferences.kt | 115 +- .../com/ahu/ahutong/ui/screen/setup/Info.kt | 25 +- .../com/ahu/ahutong/ui/screen/setup/Login.kt | 19 +- .../ui/screen/setup/LoginDynamicIsland.kt | 46 +- .../ui/state/BathroomDepositViewModel.kt | 69 +- .../ahutong/ui/state/DiscoveryViewModel.kt | 19 +- .../ui/state/ElectricityDepositViewModel.kt | 248 +- .../ahutong/ui/state/EvaluationViewModel.kt | 51 +- .../com/ahu/ahutong/ui/state/ExamViewModel.kt | 60 +- .../ui/state/FreeClassroomViewModel.kt | 71 +- .../ahu/ahutong/ui/state/LicenseViewModel.kt | 6 + .../ahutong/ui/state/LostFoundViewModel.kt | 213 +- .../com/ahu/ahutong/ui/state/MainViewModel.kt | 70 +- .../ui/state/NetworkRechargeViewModel.kt | 12 +- .../ahutong/ui/state/PreferencesViewModel.kt | 116 +- .../ahutong/ui/state/RepositoryViewModel.kt | 115 +- .../ahu/ahutong/ui/state/ScheduleViewModel.kt | 69 +- .../ahu/ahutong/ui/state/WeatherViewModel.kt | 56 +- .../java/com/ahu/ahutong/ui/theme/AHUTheme.kt | 123 +- .../ahu/ahutong/ui/theme/LiquidGlassTokens.kt | 166 + .../java/com/ahu/ahutong/utils/Navigation.kt | 189 +- app/src/main/res/xml/file_paths.xml | 1 - .../main/res/xml/network_security_config.xml | 7 +- .../ahu/ahutong/ui/screen/settings/Debug.kt | 13 + .../data/CasLoginActionResolverTest.kt | 34 + .../crawler/net/SessionRefreshPolicyTest.kt | 41 + .../ahu/ahutong/data/model/AppUiThemeTest.kt | 18 + .../RepositoryIndexRefreshPolicyTest.kt | 83 + .../security/NetworkSecurityConfigTest.kt | 25 + .../server/ApkDownloadArchitectureTest.kt | 37 + .../data/weather/WeatherR8ContractTest.kt | 26 + .../personalization/AppActionCatalogTest.kt | 10 +- .../journey/JourneyFailureIsolationTest.kt | 40 + .../screen/main/CmbRechargePageStyleTest.kt | 312 +- .../ahutong/ui/state/ExamRefreshPolicyTest.kt | 50 + .../state/FreeClassroomQueryPlanningTest.kt | 22 + .../ahutong/ui/state/ScheduleTimeRangeTest.kt | 27 + .../ui/theme/LiquidGlassArchitectureTest.kt | 88 + .../ahutong/ui/theme/LiquidGlassPolicyTest.kt | 38 + gradle/libs.versions.toml | 6 +- gradle/verification-metadata.xml | 5365 +++++++++++++++++ gradle/wrapper/gradle-wrapper.properties | 1 + settings.gradle.kts | 20 +- 141 files changed, 18082 insertions(+), 5682 deletions(-) rename app/src/{main => debug}/java/com/ahu/ahutong/ui/screen/settings/Debug.kt (97%) create mode 100644 app/src/main/java/com/ahu/ahutong/data/crawler/manager/EncryptedCookiePersistor.kt create mode 100644 app/src/main/java/com/ahu/ahutong/data/crawler/net/SessionRefreshCoordinator.kt delete mode 100644 app/src/main/java/com/ahu/ahutong/data/mock_server/MockServer.kt create mode 100644 app/src/main/java/com/ahu/ahutong/data/model/AppUiTheme.kt create mode 100644 app/src/main/java/com/ahu/ahutong/data/model/CardRechargeBank.kt create mode 100644 app/src/main/java/com/ahu/ahutong/data/security/SecureStorage.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/component/SecurePaymentPasswordDialog.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/components/AppComponents.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/components/LiquidGlassSurface.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbRechargeNativePanel.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/theme/LiquidGlassTokens.kt create mode 100644 app/src/release/java/com/ahu/ahutong/ui/screen/settings/Debug.kt create mode 100644 app/src/test/java/com/ahu/ahutong/data/CasLoginActionResolverTest.kt create mode 100644 app/src/test/java/com/ahu/ahutong/data/crawler/net/SessionRefreshPolicyTest.kt create mode 100644 app/src/test/java/com/ahu/ahutong/data/model/AppUiThemeTest.kt create mode 100644 app/src/test/java/com/ahu/ahutong/data/repository/RepositoryIndexRefreshPolicyTest.kt create mode 100644 app/src/test/java/com/ahu/ahutong/data/security/NetworkSecurityConfigTest.kt create mode 100644 app/src/test/java/com/ahu/ahutong/data/server/ApkDownloadArchitectureTest.kt create mode 100644 app/src/test/java/com/ahu/ahutong/data/weather/WeatherR8ContractTest.kt create mode 100644 app/src/test/java/com/ahu/ahutong/personalization/journey/JourneyFailureIsolationTest.kt create mode 100644 app/src/test/java/com/ahu/ahutong/ui/state/ExamRefreshPolicyTest.kt create mode 100644 app/src/test/java/com/ahu/ahutong/ui/state/FreeClassroomQueryPlanningTest.kt create mode 100644 app/src/test/java/com/ahu/ahutong/ui/state/ScheduleTimeRangeTest.kt create mode 100644 app/src/test/java/com/ahu/ahutong/ui/theme/LiquidGlassArchitectureTest.kt create mode 100644 app/src/test/java/com/ahu/ahutong/ui/theme/LiquidGlassPolicyTest.kt create mode 100644 gradle/verification-metadata.xml 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..80a8fb5c 100644 --- a/app/src/main/java/com/ahu/ahutong/MainActivity.kt +++ b/app/src/main/java/com/ahu/ahutong/MainActivity.kt @@ -31,7 +31,8 @@ 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.screen.main.CmbRechargeAutomationController import com.ahu.ahutong.ui.state.AboutViewModel import com.ahu.ahutong.ui.state.DiscoveryViewModel import com.ahu.ahutong.ui.state.LoginViewModel @@ -46,9 +47,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 +72,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 +159,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 +189,10 @@ class MainActivity : ComponentActivity() { scheduleViewModel.loadConfig() scheduleViewModel.refreshSchedule() } + + if (!BuildConfig.DEBUG) { + mainViewModel.checkApkUpdate(this@MainActivity) + } } } @@ -205,6 +208,11 @@ class MainActivity : ComponentActivity() { super.onStop() } + override fun onDestroy() { + CmbRechargeAutomationController.discard() + super.onDestroy() + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) 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/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..38b1b2f3 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,14 @@ 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.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 +16,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 +35,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 +65,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 +135,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 +161,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 +185,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 +214,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 +227,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 +238,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 +255,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 +282,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 +299,6 @@ object AHUCache { fun saveGrade(grade: Grade) { val data = Gson().toJson(grade) userPutString("grade", data) - kv.encode("grade", data) } /** @@ -250,7 +317,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 +329,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 +352,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 +374,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 +396,6 @@ object AHUCache { */ fun saveSchoolYear(schoolYear: String) { userPutString("defaultSchoolYear", schoolYear) - kv.encode("defaultSchoolYear", schoolYear) } /** @@ -350,7 +417,6 @@ object AHUCache { */ fun saveSchoolTerm(schoolTerm: String) { userPutString("defaultSchoolTerm", schoolTerm) - kv.putString("defaultSchoolTerm", schoolTerm) } /** @@ -370,7 +436,6 @@ object AHUCache { */ fun saveIsShowAllCourse(isCourse: Boolean) { userPutString("isShowAllCourse", isCourse.toString()) - kv.putBoolean("isShowAllCourse", isCourse) } fun isShowWidgetTip(): Boolean { @@ -382,12 +447,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 +473,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 +531,6 @@ object AHUCache { fun setJwxtStudentId(id: String){ userPutString("jwxt_stu_id", id) - kv.putString("jwxt_stu_id",id) } fun getJwxtStudentId() : String?{ @@ -458,7 +545,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 +560,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 +573,6 @@ object AHUCache { fun saveString(key: String ,value : String){ userPutString(key, value) - kv.putString(key,value) } fun saveRustCookies(cookiesJson: String) { @@ -497,7 +581,6 @@ object AHUCache { } else { initPutString("rust_cookies_json", cookiesJson) } - kv_init.putString("rust_cookies_json", cookiesJson) } fun getRustCookies(): String { @@ -516,7 +599,6 @@ object AHUCache { fun setAgreementAccepted(){ userPutString("agreementAccepted", true.toString()) - kv.putBoolean("agreementAccepted",true) } fun isPrivacyAccepted(): Boolean{ @@ -528,7 +610,6 @@ object AHUCache { fun setPrivacyAccepted(){ userPutString("privacyAccepted", true.toString()) - kv.putBoolean("privacyAccepted",true) } fun isBusinessAccepted(): Boolean{ @@ -540,21 +621,49 @@ 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 + } + ) } /** @@ -571,7 +680,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 +700,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 +734,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 +742,6 @@ object AHUCache { */ fun saveCardBalance(balance: Double) { userPutString("card_balance", balance.toString()) - kv.encode("card_balance", balance) } /** @@ -652,23 +757,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 +798,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 +847,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 +875,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 +897,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 +922,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 +931,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 +966,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 +990,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/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..3f45648c 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" @@ -94,10 +84,12 @@ fun CardBalanceDeposit( val cardInfo = viewModel.cardInfo.collectAsState() val accountState by viewModel.accountState.collectAsState() - val paymentState by viewModel.paymentState.collectAsState() + val agriculturalPaymentState by viewModel.paymentState.collectAsState() + val cmbRechargeState by CmbRechargeAutomationController.state.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,9 +97,43 @@ fun CardBalanceDeposit( val currentUser = remember { AHUCache.getCurrentUser() } val campusCardUserName = currentUser?.name.orEmpty() val campusCardStudentId = currentUser?.xh.orEmpty() + val paymentState = when (selectedRechargeBank) { + CardRechargeBank.CHINA_MERCHANTS_BANK -> cmbRechargeState.toPaymentState() + CardRechargeBank.AGRICULTURAL_BANK -> agriculturalPaymentState + CardRechargeBank.ALIPAY, + null -> PaymentState.Idle + } + + fun selectRechargeBank(bank: CardRechargeBank) { + if (paymentState == PaymentState.Loading) return + selectedRechargeBank = bank + AHUCache.setCardRechargeBank(bank) + if (bank == CardRechargeBank.ALIPAY) copyCampusCardInfo = false + viewModel.resetPaymentState() + CmbRechargeAutomationController.resetPaymentState() + CmbRechargeAutomationController.onBankSelected(context, bank) + } + + fun submitRecharge() { + when (selectedRechargeBank) { + CardRechargeBank.ALIPAY -> showAlipayConfirmDialog = true + CardRechargeBank.CHINA_MERCHANTS_BANK -> { + behaviorReporter.organic(AppActionId.SUBMIT_CMB_CARD_RECHARGE) + CmbRechargeAutomationController.submit(context = context, amount = amount) + } + CardRechargeBank.AGRICULTURAL_BANK -> { + behaviorReporter.organic(AppActionId.SUBMIT_CARD_RECHARGE) + viewModel.charge(amount) + } + null -> Unit + } + } LaunchedEffect(Unit) { viewModel.load() + if (selectedRechargeBank == CardRechargeBank.CHINA_MERCHANTS_BANK) { + (context as? android.app.Activity)?.let(CmbRechargeAutomationController::schedulePreload) + } } LaunchedEffect(mockRefreshRevision) { @@ -115,28 +141,90 @@ 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) + if (selectedRechargeBank == CardRechargeBank.CHINA_MERCHANTS_BANK) { + CmbRechargeAutomationController.resetPaymentState() + } else { + viewModel.resetPaymentState() + } + } else if (paymentState is PaymentState.Error) { + delay(PAYMENT_RESULT_DISPLAY_DURATION_MS) + if (selectedRechargeBank == CardRechargeBank.CHINA_MERCHANTS_BANK) { + CmbRechargeAutomationController.resetPaymentState() + } else { + 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 +238,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 +256,7 @@ fun CardBalanceDeposit( is CardAccountState.Error -> { Text( text = "加载失败", - color = Color.Red + color = MaterialTheme.colorScheme.error ) } } @@ -190,229 +278,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,87 +419,20 @@ 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 - ) + openAlipayCampusCard(context) + showAlipayConfirmDialog = false + } + ) { Text("确认") } } - }, - 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") - } - .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 - ) - } - } + if (cmbRechargeState.phase == CmbRechargePaymentPhase.PASSWORD_REQUIRED) { + CmbRechargeQueryPasswordDialog( + onCancel = CmbRechargeAutomationController::cancelPassword, + onConfirm = CmbRechargeAutomationController::submitPassword ) } @@ -512,6 +440,24 @@ fun CardBalanceDeposit( } +private val CardRechargeBank.displayName: String + get() = when (this) { + CardRechargeBank.AGRICULTURAL_BANK -> "中国农业银行" + CardRechargeBank.CHINA_MERCHANTS_BANK -> "招商银行" + CardRechargeBank.ALIPAY -> "支付宝" + } + +private fun CmbRechargeAutomationState.toPaymentState(): PaymentState = when (phase) { + CmbRechargePaymentPhase.IDLE, + CmbRechargePaymentPhase.PASSWORD_REQUIRED -> PaymentState.Idle + + CmbRechargePaymentPhase.LOADING -> PaymentState.Loading + CmbRechargePaymentPhase.SUCCESS -> PaymentState.Success("招商银行") + CmbRechargePaymentPhase.ERROR -> PaymentState.Error( + errorMessage ?: "招商银行充值失败,请重试" + ) +} + private enum class CampusCardIdentityCopyState { Complete, Partial, @@ -529,9 +475,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 +489,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 index 25759370..a167bfb3 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbCardRecharge.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbCardRecharge.kt @@ -1,16 +1,25 @@ package com.ahu.ahutong.ui.screen.main import android.annotation.SuppressLint +import android.app.Activity import android.content.ActivityNotFoundException +import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build +import android.os.Looper +import android.os.MessageQueue import android.os.SystemClock +import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout import android.widget.Toast import android.webkit.WebChromeClient import android.webkit.JavascriptInterface import android.webkit.WebResourceError import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient @@ -28,8 +37,9 @@ 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.statusBarsPadding import androidx.compose.foundation.layout.width -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 @@ -51,22 +61,38 @@ 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.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.zIndex 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.data.dao.AHUCache +import com.ahu.ahutong.data.model.CardRechargeBank import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.personalization.action.AppActionId import com.ahu.ahutong.personalization.ui.rememberBehaviorActionReporter +import com.google.gson.Gson +import com.ahu.ahutong.ui.components.AppPageHeader import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import okhttp3.Cookie import java.net.URI +import kotlin.coroutines.resume internal data class CmbRechargeNormalizedBounds( val left: Float, @@ -75,6 +101,11 @@ internal data class CmbRechargeNormalizedBounds( val height: Float ) +private const val CMB_NATIVE_BOOTSTRAP_TIMEOUT_MS = 15_000L +private const val CMB_PASSWORD_DISPATCH_TIMEOUT_MS = 15_000L +private const val CMB_SUCCESS_CONFIRMATION_TIMEOUT_MS = 8_000L +internal const val CMB_RECHARGE_PRELOAD_VALIDITY_MS = 3 * 60 * 1_000L + private const val CMB_SUBMIT_OBSERVER_SCRIPT = """ (function(){ if (window.__ahutongSubmitObserverInstalled) return; @@ -96,6 +127,605 @@ private const val CMB_SUBMIT_OBSERVER_SCRIPT = """ })(); """ +private val CMB_NATIVE_STATE_BRIDGE_SCRIPT = """ +(function(){ + function findRechargeComponent(){ + var root = document.querySelector('#app'); + var queue = root && root.__vue__ ? [root.__vue__] : []; + var seen = []; + while (queue.length) { + var current = queue.shift(); + if (!current || seen.indexOf(current) >= 0) continue; + seen.push(current); + if (typeof current.rechargeOrders === 'function' && Array.isArray(current.cardList)) { + return current; + } + if (current.${'$'}children) queue = queue.concat(current.${'$'}children); + } + return null; + } + function publish(){ + var component = findRechargeComponent(); + if (!component || !component.cardList.length) return; + var account = component.cardList[component.cardIndex || 0] || component.cardList[0]; + var methods = (component.payType || []).map(function(item, index){ + return { + pageIndex: index, + name: String(item.payPrdName || ('支付方式 ' + (index + 1))) + }; + }); + var payload = JSON.stringify({ + studentNumber: String(account.empno || ''), + balance: Number(account.balance || 0), + paymentMethods: methods + }); + if (payload === window.__ahutongRechargeLastPayload) return; + window.__ahutongRechargeLastPayload = payload; + window.AhuTongRechargeBridge.onRechargeState(payload); + } + window.__ahutongPublishRechargeState = publish; + if (!window.__ahutongRechargeStateObserverInstalled) { + window.__ahutongRechargeStateObserverInstalled = true; + window.setInterval(publish, 500); + } + publish(); +})(); +""" + +private const val CMB_NATIVE_PAYMENT_UI_SCRIPT = """ +(function(){ + function visible(node){ + if (!node) return false; + var style = window.getComputedStyle(node); + return style.display !== 'none' && style.visibility !== 'hidden' && + node.getClientRects().length > 0; + } + function notify(){ + var sheets = Array.from(document.querySelectorAll('.van-action-sheet')); + var sheet = sheets.find(visible); + var title = sheet + ? ((sheet.querySelector('.van-action-sheet__header') || {}).innerText || '') + : ''; + var passwordDots = sheet + ? Array.from(sheet.querySelectorAll('.van-password-input__security i')).filter(visible).length + : 0; + var toast = Array.from(document.querySelectorAll('.van-toast--fail')).find(visible); + window.AhuTongRechargeBridge.onPaymentUiState(JSON.stringify({ + passwordRequired: title.indexOf('查询密码') >= 0 && passwordDots === 0, + error: toast ? (toast.innerText || '') : '' + })); + } + if (!window.__ahutongPaymentUiObserverInstalled) { + if (!document.body) return 'body-not-ready'; + var observer = new MutationObserver(notify); + observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['style', 'class'] + }); + window.__ahutongPaymentUiObserverInstalled = true; + window.__ahutongPaymentUiObserver = observer; + window.setInterval(notify, 250); + } + notify(); +})(); +""" + +internal enum class CmbRechargePaymentPhase { + IDLE, + LOADING, + PASSWORD_REQUIRED, + SUCCESS, + ERROR +} + +internal data class CmbRechargeAutomationState( + val phase: CmbRechargePaymentPhase = CmbRechargePaymentPhase.IDLE, + val errorMessage: String? = null +) + +internal fun isCmbRechargeSessionFresh( + readyAtElapsedMs: Long, + nowElapsedMs: Long +): Boolean = readyAtElapsedMs > 0L && + nowElapsedMs >= readyAtElapsedMs && + nowElapsedMs - readyAtElapsedMs < CMB_RECHARGE_PRELOAD_VALIDITY_MS + +internal fun canDispatchCmbRecharge( + amount: String?, + password: String?, + hasFreshSession: Boolean +): Boolean = !amount.isNullOrBlank() && + !password.isNullOrBlank() && + hasFreshSession + +internal fun canDispatchCmbPassword( + password: String?, + dispatchInProgress: Boolean, + hasWebView: Boolean +): Boolean = !password.isNullOrBlank() && !dispatchInProgress && hasWebView + +internal fun isCmbSessionExpiredMessage(message: String): Boolean { + val normalized = message.trim().lowercase() + return listOf( + "登录失效", + "登录已失效", + "登录过期", + "登录已过期", + "登录超时", + "会话失效", + "会话已失效", + "会话过期", + "会话已过期", + "请重新登录", + "token失效", + "token已失效" + ).any(normalized::contains) +} + +internal fun shouldRecoverCmbSession( + message: String, + recoveryAttempted: Boolean, + amount: String?, + password: String? +): Boolean = !recoveryAttempted && + !amount.isNullOrBlank() && + !password.isNullOrBlank() && + isCmbSessionExpiredMessage(message) + +/** + * Owns the hidden CMB WebView so the visible recharge screen can stay identical to the + * existing Agricultural Bank flow. The WebView is attached invisibly to keep its Vue/JS + * runtime alive, and every ready session is destroyed after three minutes. + */ +internal object CmbRechargeAutomationController { + private const val TAG = "CmbRechargeAutomation" + private const val PRELOAD_START_DELAY_MS = 1_000L + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val _state = MutableStateFlow(CmbRechargeAutomationState()) + val state: StateFlow = _state.asStateFlow() + + private var webView: WebView? = null + private var hostRoot: ViewGroup? = null + private var nativeData: CmbRechargeNativeData? = null + private var readyAtElapsedMs = 0L + private var generation = 0 + private var pendingAmount: String? = null + private var pendingPassword: String? = null + private var submissionAmount: String? = null + private var submissionPassword: String? = null + private var submissionContext: Context? = null + private var officialPasswordPromptVisible = false + private var passwordDispatchInProgress = false + private var sessionRecoveryAttempted = false + private var userSubmissionActive = false + private var scheduledPreloadJob: Job? = null + private var sessionLoadJob: Job? = null + private var expiryJob: Job? = null + private var bootstrapTimeoutJob: Job? = null + private var paymentTimeoutJob: Job? = null + + fun schedulePreload(activity: Activity) { + if ( + AHUCache.getCardRechargeBank() != CardRechargeBank.CHINA_MERCHANTS_BANK || + !AHUCache.isLogin() || + hasFreshSession() || + sessionLoadJob?.isActive == true || + scheduledPreloadJob?.isActive == true + ) { + return + } + + scheduledPreloadJob = scope.launch { + delay(PRELOAD_START_DELAY_MS) + if ( + AHUCache.getCardRechargeBank() != CardRechargeBank.CHINA_MERCHANTS_BANK || + !AHUCache.isLogin() + ) { + return@launch + } + loadSession(activity, deferWebViewCreationUntilIdle = true) + } + } + + fun onBankSelected(context: Context, bank: CardRechargeBank) { + if (bank == CardRechargeBank.CHINA_MERCHANTS_BANK) { + (context as? Activity)?.let(::schedulePreload) + } + // Keep a fresh CMB session alive while the user compares banks. Its existing + // three-minute expiry remains authoritative and prevents repeated login traffic. + } + + fun submit(context: Context, amount: String) { + scope.launch { + pendingAmount = amount + pendingPassword = null + submissionAmount = amount + submissionPassword = null + submissionContext = context + officialPasswordPromptVisible = false + passwordDispatchInProgress = false + sessionRecoveryAttempted = false + userSubmissionActive = true + _state.value = CmbRechargeAutomationState( + CmbRechargePaymentPhase.PASSWORD_REQUIRED + ) + } + } + + fun submitPassword(password: String) { + if (!userSubmissionActive || pendingAmount == null && !officialPasswordPromptVisible) { + failUserSubmission("招商银行充值会话已失效,请重试") + return + } + + pendingPassword = password + submissionPassword = password + _state.value = CmbRechargeAutomationState(CmbRechargePaymentPhase.LOADING) + if (officialPasswordPromptVisible) { + dispatchPendingPassword() + return + } + + val context = submissionContext + if (context == null) { + failUserSubmission("招商银行充值会话已失效,请重试") + } else if (!hasFreshSession()) { + destroySession() + loadSession(context, deferWebViewCreationUntilIdle = false) + } else { + dispatchPendingRecharge() + } + } + + fun cancelPassword() { + paymentTimeoutJob?.cancel() + if (officialPasswordPromptVisible) webView?.cancelCmbRechargePassword() + pendingAmount = null + pendingPassword = null + submissionAmount = null + submissionPassword = null + submissionContext = null + officialPasswordPromptVisible = false + passwordDispatchInProgress = false + sessionRecoveryAttempted = false + userSubmissionActive = false + _state.value = CmbRechargeAutomationState() + } + + fun resetPaymentState() { + paymentTimeoutJob?.cancel() + pendingAmount = null + pendingPassword = null + submissionAmount = null + submissionPassword = null + submissionContext = null + officialPasswordPromptVisible = false + passwordDispatchInProgress = false + sessionRecoveryAttempted = false + userSubmissionActive = false + _state.value = CmbRechargeAutomationState() + } + + fun discard() { + scope.launch { + scheduledPreloadJob?.cancel() + sessionLoadJob?.cancel() + pendingAmount = null + pendingPassword = null + submissionAmount = null + submissionPassword = null + submissionContext = null + officialPasswordPromptVisible = false + passwordDispatchInProgress = false + sessionRecoveryAttempted = false + userSubmissionActive = false + destroySession() + _state.value = CmbRechargeAutomationState() + } + } + + private fun hasFreshSession(nowElapsedMs: Long = SystemClock.elapsedRealtime()): Boolean = + webView != null && + nativeData != null && + isCmbRechargeNativeEntryUrl(webView?.url) && + isCmbRechargeSessionFresh(readyAtElapsedMs, nowElapsedMs) + + private fun loadSession(context: Context, deferWebViewCreationUntilIdle: Boolean) { + if (sessionLoadJob?.isActive == true) return + val activity = context as? Activity + val applicationContext = context.applicationContext + sessionLoadJob = scope.launch { + val token = withContext(Dispatchers.IO) { TokenManager.awaitToken() } + if (token.isNullOrBlank()) { + handleSessionLoadFailure("校园卡登录凭证暂未就绪,请稍后重试") + return@launch + } + + if (deferWebViewCreationUntilIdle && pendingAmount == null) { + awaitMainThreadIdle() + } + if ( + pendingAmount == null && + AHUCache.getCardRechargeBank() != CardRechargeBank.CHINA_MERCHANTS_BANK + ) { + return@launch + } + + createAndLoadSession( + context = activity ?: applicationContext, + entryUrl = buildCmbRechargeEntryUrl(token) + ) + } + } + + private fun createAndLoadSession(context: Context, entryUrl: String) { + destroySession() + generation += 1 + val sessionGeneration = generation + lateinit var createdView: WebView + createdView = createCmbRechargeWebView( + context = context, + pageBackgroundColor = android.graphics.Color.TRANSPARENT, + pageStyleScript = { "" }, + onLoadingChanged = {}, + onProgressChanged = {}, + onSuccessPageChanged = {}, + onSuccessReturnBoundsChanged = { bounds -> + if ( + sessionGeneration == generation && + shouldConfirmCmbRechargeSuccess(createdView.url, bounds) + ) { + completeUserSubmission() + } + }, + onNativeDataChanged = { data -> + if (sessionGeneration != generation) return@createCmbRechargeWebView + nativeData = data + readyAtElapsedMs = SystemClock.elapsedRealtime() + bootstrapTimeoutJob?.cancel() + scheduleExpiry(sessionGeneration) + dispatchPendingRecharge() + }, + onPaymentUiStateChanged = { requiresPassword, pageError -> + if (sessionGeneration != generation || !userSubmissionActive) { + return@createCmbRechargeWebView + } + if (pageError.isNotBlank()) { + if (!recoverSubmissionAfterSessionExpiry(pageError)) { + failUserSubmission(pageError) + } + } else if (requiresPassword) { + officialPasswordPromptVisible = true + dispatchPendingPassword() + } + }, + onPageChanged = { url -> + Log.d(TAG, "CMB navigation: ${safeCmbPageLocation(url)}") + }, + onMainFrameError = { message -> + if (sessionGeneration == generation) handleSessionLoadFailure(message) + }, + onExternalLink = { + if (sessionGeneration == generation) { + handleSessionLoadFailure("招商银行充值需要打开未受支持的外部页面,请重试") + } + }, + onSubmitIntent = {} + ) + createdView.updateCmbRechargeWebViewVisibility(false) + attachHiddenWebView(context as? Activity, createdView) + syncYcardCookiesToWebView(createdView) + createdView.cmbRechargeState?.requestVersion = sessionGeneration + webView = createdView + createdView.loadUrl(entryUrl) + + bootstrapTimeoutJob = scope.launch { + delay(CMB_NATIVE_BOOTSTRAP_TIMEOUT_MS) + if (sessionGeneration == generation && nativeData == null) { + handleSessionLoadFailure("招商银行充值页面加载超时,请重试") + } + } + } + + private fun attachHiddenWebView(activity: Activity?, view: WebView) { + val root = activity?.findViewById(android.R.id.content) ?: return + hostRoot = root + root.addView( + view, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + } + + private fun dispatchPendingRecharge() { + val amount = pendingAmount ?: return + if (!canDispatchCmbRecharge(amount, pendingPassword, hasFreshSession())) { + if (pendingPassword == null) { + _state.value = CmbRechargeAutomationState( + CmbRechargePaymentPhase.PASSWORD_REQUIRED + ) + } + return + } + val data = nativeData ?: return + val currentView = webView ?: return + val paymentMethod = data.paymentMethods.firstOrNull() + if (paymentMethod == null) { + failUserSubmission("招商银行未找到可用的绑定银行卡") + return + } + + pendingAmount = null + _state.value = CmbRechargeAutomationState(CmbRechargePaymentPhase.LOADING) + startPaymentTimeout("招商银行充值请求超时,请重试") + currentView.submitCmbRecharge( + amount = amount, + paymentMethodIndex = paymentMethod.pageIndex, + onRejected = ::failUserSubmission + ) + } + + private fun dispatchPendingPassword() { + if (passwordDispatchInProgress) return + val password = pendingPassword + val currentView = webView + if ( + !canDispatchCmbPassword( + password = password, + dispatchInProgress = passwordDispatchInProgress, + hasWebView = currentView != null + ) + ) { + _state.value = CmbRechargeAutomationState( + CmbRechargePaymentPhase.PASSWORD_REQUIRED + ) + return + } + val dispatchPassword = password ?: return + val dispatchView = currentView ?: return + + passwordDispatchInProgress = true + pendingPassword = null + _state.value = CmbRechargeAutomationState(CmbRechargePaymentPhase.LOADING) + startPaymentTimeout("查询密码提交超时,请重试") + dispatchView.submitCmbRechargePassword( + password = dispatchPassword, + onRejected = ::failUserSubmission + ) + } + + private fun completeUserSubmission() { + if (!userSubmissionActive) return + paymentTimeoutJob?.cancel() + pendingAmount = null + pendingPassword = null + submissionAmount = null + submissionPassword = null + submissionContext = null + officialPasswordPromptVisible = false + passwordDispatchInProgress = false + sessionRecoveryAttempted = false + userSubmissionActive = false + _state.value = CmbRechargeAutomationState(CmbRechargePaymentPhase.SUCCESS) + scope.launch { + destroySession() + } + } + + private fun startPaymentTimeout(message: String) { + paymentTimeoutJob?.cancel() + paymentTimeoutJob = scope.launch { + delay(CMB_PASSWORD_DISPATCH_TIMEOUT_MS) + if (userSubmissionActive) failUserSubmission(message) + } + } + + private fun recoverSubmissionAfterSessionExpiry(message: String): Boolean { + val amount = submissionAmount + val password = submissionPassword + val context = submissionContext + if ( + context == null || + !shouldRecoverCmbSession( + message = message, + recoveryAttempted = sessionRecoveryAttempted, + amount = amount, + password = password + ) + ) { + return false + } + + sessionRecoveryAttempted = true + paymentTimeoutJob?.cancel() + pendingAmount = amount + pendingPassword = password + officialPasswordPromptVisible = false + passwordDispatchInProgress = false + _state.value = CmbRechargeAutomationState(CmbRechargePaymentPhase.LOADING) + destroySession() + loadSession(context, deferWebViewCreationUntilIdle = false) + return true + } + + private fun failUserSubmission(message: String) { + paymentTimeoutJob?.cancel() + pendingAmount = null + pendingPassword = null + submissionAmount = null + submissionPassword = null + submissionContext = null + officialPasswordPromptVisible = false + passwordDispatchInProgress = false + sessionRecoveryAttempted = false + userSubmissionActive = false + _state.value = CmbRechargeAutomationState( + phase = CmbRechargePaymentPhase.ERROR, + errorMessage = message + ) + scope.launch { destroySession() } + } + + private fun handleSessionLoadFailure(message: String) { + if (userSubmissionActive) { + failUserSubmission(message) + } else { + Log.w(TAG, message) + scope.launch { destroySession() } + } + } + + private fun scheduleExpiry(sessionGeneration: Int) { + expiryJob?.cancel() + expiryJob = scope.launch { + delay(CMB_RECHARGE_PRELOAD_VALIDITY_MS) + if (sessionGeneration == generation && !userSubmissionActive) { + Log.d(TAG, "Discarding expired three-minute CMB preload session") + destroySession() + } + } + } + + private fun destroySession() { + expiryJob?.cancel() + expiryJob = null + bootstrapTimeoutJob?.cancel() + bootstrapTimeoutJob = null + paymentTimeoutJob?.cancel() + paymentTimeoutJob = null + passwordDispatchInProgress = false + nativeData = null + readyAtElapsedMs = 0L + generation += 1 + + val currentView = webView + webView = null + currentView?.cmbRechargeState?.dispose() + (currentView?.parent as? ViewGroup)?.removeView(currentView) + hostRoot = null + currentView?.stopLoading() + currentView?.removeAllViews() + currentView?.destroy() + } + + private suspend fun awaitMainThreadIdle() { + suspendCancellableCoroutine { continuation -> + val queue = Looper.myQueue() + val idleHandler = MessageQueue.IdleHandler { + if (continuation.isActive) continuation.resume(Unit) + false + } + queue.addIdleHandler(idleHandler) + continuation.invokeOnCancellation { queue.removeIdleHandler(idleHandler) } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun CmbCardRecharge( @@ -132,29 +762,69 @@ fun CmbCardRecharge( var loadRequestVersion by remember { mutableIntStateOf(0) } var isLoading by remember { mutableStateOf(true) } var isRechargeSuccessPage by remember { mutableStateOf(false) } + var nativeData by remember { mutableStateOf(null) } + var showWebContent by remember { mutableStateOf(false) } + var forceWebContent by remember { mutableStateOf(false) } + var isSubmitting by remember { mutableStateOf(false) } var successReturnBounds by remember { mutableStateOf(null) } var errorMessage by remember { mutableStateOf(null) } - - BackHandler(onBack = onExit) + var queryPasswordRequired by remember { mutableStateOf(false) } + var nativeSuccess by remember { mutableStateOf(false) } + var isPasswordDispatching by remember { mutableStateOf(false) } + var allowWebContentReveal by remember { mutableStateOf(false) } + val isWebContentVisible = showWebContent || forceWebContent fun reloadEntry() { progress = 0 isLoading = true errorMessage = null isRechargeSuccessPage = false + nativeData = null + showWebContent = false + forceWebContent = false + isSubmitting = false + queryPasswordRequired = false + nativeSuccess = false + isPasswordDispatching = false + allowWebContentReveal = false successReturnBounds = null webView?.stopLoading() loadRequestVersion += 1 } + val handleBack: () -> Unit = { + val currentWebView = webView + if (nativeSuccess) { + latestRechargeSuccessExit.value() + } else if (forceWebContent && isCmbRechargeNativeEntryUrl(currentWebView?.url)) { + forceWebContent = false + allowWebContentReveal = false + } else if (isWebContentVisible && currentWebView?.canGoBack() == true) { + currentWebView.goBack() + } else if (isWebContentVisible) { + reloadEntry() + } else { + onExit() + } + } + BackHandler(onBack = handleBack) + LaunchedEffect(tokenRequestVersion) { progress = 0 isLoading = true errorMessage = null entryUrl = null isRechargeSuccessPage = false + nativeData = null + showWebContent = false + forceWebContent = false + isSubmitting = false + queryPasswordRequired = false + nativeSuccess = false + isPasswordDispatching = false + allowWebContentReveal = false successReturnBounds = null val token = withContext(Dispatchers.IO) { TokenManager.awaitToken() } if (token.isNullOrBlank()) { @@ -168,9 +838,10 @@ fun CmbCardRecharge( DisposableEffect(Unit) { onDispose { - webView?.stopLoading() - webView?.cmbRechargeState?.boundsLocator?.dispose() - webView?.destroy() + val currentView = webView + currentView?.stopLoading() + currentView?.cmbRechargeState?.dispose() + currentView?.destroy() webView = null } } @@ -182,32 +853,57 @@ fun CmbCardRecharge( } } + LaunchedEffect(entryUrl, loadRequestVersion, nativeData, errorMessage, nativeSuccess) { + if ( + entryUrl == null || + nativeData != null || + errorMessage != null || + nativeSuccess + ) { + return@LaunchedEffect + } + delay(CMB_NATIVE_BOOTSTRAP_TIMEOUT_MS) + if (nativeData == null && errorMessage == null && !nativeSuccess) { + isLoading = false + errorMessage = "充值信息加载超时,请检查网络后重试" + } + } + + LaunchedEffect(isPasswordDispatching) { + if (!isPasswordDispatching) return@LaunchedEffect + delay(CMB_PASSWORD_DISPATCH_TIMEOUT_MS) + if (isPasswordDispatching) { + webView?.cancelCmbRechargePassword() + isPasswordDispatching = false + isSubmitting = false + allowWebContentReveal = false + errorMessage = "查询密码提交超时,请重试" + } + } + + LaunchedEffect(isRechargeSuccessPage, nativeSuccess) { + if (!isRechargeSuccessPage || nativeSuccess) return@LaunchedEffect + delay(CMB_SUCCESS_CONFIRMATION_TIMEOUT_MS) + if (isRechargeSuccessPage && !nativeSuccess) { + isSubmitting = false + allowWebContentReveal = true + showWebContent = true + forceWebContent = true + } + } + 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 - ) + AppPageHeader( + title = "招商银行充值", + onBack = handleBack, + modifier = Modifier + .zIndex(1f) + .statusBarsPadding() ) } ) { contentPadding -> @@ -216,11 +912,14 @@ fun CmbCardRecharge( .fillMaxSize() .padding(contentPadding) .background(pageBackgroundColor) + .clipToBounds() ) { entryUrl?.let { url -> val requestVersion = loadRequestVersion AndroidView( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .clipToBounds(), factory = { viewContext -> createCmbRechargeWebView( context = viewContext, @@ -230,11 +929,80 @@ fun CmbCardRecharge( onProgressChanged = { progress = it }, onSuccessPageChanged = { isSuccessPage -> isRechargeSuccessPage = isSuccessPage - if (!isSuccessPage) successReturnBounds = null + if (!isSuccessPage) { + nativeSuccess = false + successReturnBounds = null + } + }, + onSuccessReturnBoundsChanged = { bounds -> + successReturnBounds = bounds + if (shouldConfirmCmbRechargeSuccess(webView?.url, bounds)) { + nativeSuccess = true + errorMessage = null + isSubmitting = false + queryPasswordRequired = false + isPasswordDispatching = false + allowWebContentReveal = false + showWebContent = false + forceWebContent = false + } + }, + onNativeDataChanged = { pageData -> + nativeData = pageData + errorMessage = null + showWebContent = false + }, + onPaymentUiStateChanged = { requiresPassword, pageError -> + queryPasswordRequired = requiresPassword && !isPasswordDispatching + if (pageError.isNotBlank()) { + errorMessage = pageError + isSubmitting = false + isPasswordDispatching = false + allowWebContentReveal = false + } + }, + onPageChanged = { currentUrl -> + if (!isCmbRechargeNativeEntryUrl(currentUrl)) { + queryPasswordRequired = false + isPasswordDispatching = false + } + if ( + isCmbRechargeHiddenFlowUrl(currentUrl) || + isCmbRechargeSuccessUrl(currentUrl) + ) { + if (showWebContent) { + forceWebContent = false + allowWebContentReveal = false + } + showWebContent = false + } else if (isCmbRechargeInsecureEntryUrl(currentUrl)) { + allowWebContentReveal = true + showWebContent = true + forceWebContent = true + isSubmitting = false + } else if ( + shouldRevealCmbRechargeWebContent( + url = currentUrl, + revealAllowed = allowWebContentReveal + ) + ) { + showWebContent = true + isSubmitting = false + } else { + showWebContent = false + } }, - onSuccessReturnBoundsChanged = { successReturnBounds = it }, onMainFrameError = { error -> errorMessage = error + isRechargeSuccessPage = false + nativeSuccess = false + successReturnBounds = null + isSubmitting = false + queryPasswordRequired = false + isPasswordDispatching = false + showWebContent = false + forceWebContent = false + allowWebContentReveal = false }, onExternalLink = { externalUrl -> openExternalLink(context, externalUrl) @@ -243,6 +1011,7 @@ fun CmbCardRecharge( behaviorReporter.organic(AppActionId.SUBMIT_CMB_CARD_RECHARGE) } ).also { created -> + created.updateCmbRechargeWebViewVisibility(isWebContentVisible) syncYcardCookiesToWebView(created) created.cmbRechargeState?.requestVersion = requestVersion created.loadUrl(url) @@ -251,6 +1020,7 @@ fun CmbCardRecharge( }, update = { currentView -> currentView.setBackgroundColor(pageBackgroundColor.toArgb()) + currentView.updateCmbRechargeWebViewVisibility(isWebContentVisible) if (currentView.cmbRechargeState?.requestVersion != requestVersion) { syncYcardCookiesToWebView(currentView) currentView.cmbRechargeState?.requestVersion = requestVersion @@ -261,7 +1031,7 @@ fun CmbCardRecharge( ) } - if (isRechargeSuccessPage) { + if (isRechargeSuccessPage && isWebContentVisible) { successReturnBounds?.let { bounds -> CmbRechargeSuccessReturnOverlay( bounds = bounds, @@ -273,14 +1043,78 @@ fun CmbCardRecharge( } } - if (isLoading) { - CircularProgressIndicator( + if (!isWebContentVisible && nativeSuccess) { + CmbRechargeNativeSuccessPanel(onDone = latestRechargeSuccessExit.value) + } else if (!isWebContentVisible) { + CmbRechargeNativePanel( + data = nativeData, + errorMessage = errorMessage, + isSubmitting = isSubmitting, + onRetry = { + if (entryUrl == null) { + tokenRequestVersion += 1 + } else { + reloadEntry() + } + }, + onManagePaymentMethods = { + errorMessage = null + allowWebContentReveal = true + forceWebContent = true + }, + onSubmit = { amount, paymentMethodIndex -> + behaviorReporter.organic(AppActionId.SUBMIT_CMB_CARD_RECHARGE) + errorMessage = null + isSubmitting = true + allowWebContentReveal = true + forceWebContent = false + webView?.submitCmbRecharge( + amount = amount, + paymentMethodIndex = paymentMethodIndex, + onRejected = { message -> + isSubmitting = false + allowWebContentReveal = false + forceWebContent = false + errorMessage = message + } + ) + } + ) + } + + if (queryPasswordRequired) { + CmbRechargeQueryPasswordDialog( + onCancel = { + queryPasswordRequired = false + isSubmitting = false + isPasswordDispatching = false + allowWebContentReveal = false + webView?.cancelCmbRechargePassword() + }, + onConfirm = { password -> + queryPasswordRequired = false + isPasswordDispatching = true + webView?.submitCmbRechargePassword( + password = password, + onRejected = { message -> + isSubmitting = false + isPasswordDispatching = false + allowWebContentReveal = false + errorMessage = message + } + ) + } + ) + } + + if (isWebContentVisible && isLoading) { + AppCircularProgressIndicator( modifier = Modifier.align(Alignment.Center), color = colorScheme.primary ) } - if (progress in 1..99) { + if (isWebContentVisible && progress in 1..99) { LinearProgressIndicator( progress = { progress / 100f }, modifier = Modifier @@ -289,7 +1123,7 @@ fun CmbCardRecharge( ) } - errorMessage?.let { message -> + if (isWebContentVisible) errorMessage?.let { message -> Column( modifier = Modifier .align(Alignment.Center) @@ -359,6 +1193,9 @@ private fun createCmbRechargeWebView( onProgressChanged: (Int) -> Unit, onSuccessPageChanged: (Boolean) -> Unit, onSuccessReturnBoundsChanged: (CmbRechargeNormalizedBounds?) -> Unit, + onNativeDataChanged: (CmbRechargeNativeData) -> Unit, + onPaymentUiStateChanged: (requiresPassword: Boolean, error: String) -> Unit, + onPageChanged: (String?) -> Unit, onMainFrameError: (String) -> Unit, onExternalLink: (String) -> Unit, onSubmitIntent: () -> Unit @@ -381,6 +1218,10 @@ private fun createCmbRechargeWebView( } android.webkit.CookieManager.getInstance().setAcceptCookie(true) addJavascriptInterface(CmbBehaviorBridge(this, onSubmitIntent), "AhuTongBehaviorBridge") + addJavascriptInterface( + CmbRechargeStateBridge(this, onNativeDataChanged, onPaymentUiStateChanged), + "AhuTongRechargeBridge" + ) val boundsLocator = CmbRechargeBoundsLocator(this, onSuccessReturnBoundsChanged) tag = CmbRechargeWebViewState(boundsLocator = boundsLocator) @@ -389,6 +1230,9 @@ private fun createCmbRechargeWebView( onProgressChanged(newProgress) if (newProgress >= 100) { onLoadingChanged(false) + if (view != null && isCmbRechargeNativeEntryUrl(view.url)) { + view.evaluateJavascript(CMB_NATIVE_STATE_BRIDGE_SCRIPT, null) + } } } } @@ -412,9 +1256,18 @@ private fun createCmbRechargeWebView( onExternalLink(targetUri.toString()) return true } - return if (isInternalCmbRechargeUrl(targetUri)) { + val upgradedCashierUrl = buildCmbHttpsCashierUrl(targetUri.toString()) + if (upgradedCashierUrl != null && upgradedCashierUrl != targetUri.toString()) { + view?.loadUrl(upgradedCashierUrl) + return true + } + return if (isCmbRechargeAllowedMainFrameUrl(targetUri.toString())) { false } else { + Log.w( + "CmbRechargeNavigation", + "Blocked main-frame navigation to ${safeCmbPageLocation(targetUri.toString())}" + ) onExternalLink(targetUri.toString()) true } @@ -424,27 +1277,51 @@ private fun createCmbRechargeWebView( onLoadingChanged(true) boundsLocator.clear() updateSuccessPage(url) + onPageChanged(url) super.onPageStarted(view, url, favicon) } override fun onPageFinished(view: WebView?, url: String?) { onLoadingChanged(false) updateSuccessPage(url) + onPageChanged(url) if (view != null) { + if (url?.contains("synjones-auth", ignoreCase = true) == false) { + // Do not retain the short-lived bootstrap token URL in WebView history. + view.clearHistory() + } applyCmbRechargePageStyle(view, url, pageStyleScript()) if (url?.let(Uri::parse)?.let(::isAuditedCmbSubmitPage) == true) { view.evaluateJavascript(CMB_SUBMIT_OBSERVER_SCRIPT, null) } + if (isCmbRechargeNativeEntryUrl(url)) { + view.evaluateJavascript(CMB_NATIVE_STATE_BRIDGE_SCRIPT, null) + view.evaluateJavascript(CMB_NATIVE_PAYMENT_UI_SCRIPT, null) + } boundsLocator.locate(url) } super.onPageFinished(view, url) } + override fun onPageCommitVisible(view: WebView?, url: String?) { + onPageChanged(url) + if (view != null && isCmbRechargeNativeEntryUrl(url)) { + view.evaluateJavascript(CMB_NATIVE_STATE_BRIDGE_SCRIPT, null) + view.evaluateJavascript(CMB_NATIVE_PAYMENT_UI_SCRIPT, null) + } + super.onPageCommitVisible(view, url) + } + override fun doUpdateVisitedHistory( view: WebView?, url: String?, isReload: Boolean ) { + onPageChanged(url) + if (view != null && isCmbRechargeNativeEntryUrl(url)) { + view.evaluateJavascript(CMB_NATIVE_STATE_BRIDGE_SCRIPT, null) + view.evaluateJavascript(CMB_NATIVE_PAYMENT_UI_SCRIPT, null) + } if (updateSuccessPage(url) && view != null) boundsLocator.locate(url) super.doUpdateVisitedHistory(view, url, isReload) } @@ -462,10 +1339,114 @@ private fun createCmbRechargeWebView( } super.onReceivedError(view, request, error) } + + override fun onReceivedHttpError( + view: WebView?, + request: WebResourceRequest?, + errorResponse: WebResourceResponse? + ) { + if (request?.isForMainFrame == true) { + val statusCode = errorResponse?.statusCode + val pageLocation = safeCmbPageLocation(request.url?.toString()) + if (statusCode == 412 && isCmbLoginRedirectUrl(request.url?.toString())) { + Log.w( + "CmbRechargeHttp", + "CMB login redirect returned HTTP 412; awaiting page retry" + ) + onLoadingChanged(true) + super.onReceivedHttpError(view, request, errorResponse) + return + } + + val upgradedCashierUrl = if (statusCode == 412) { + buildCmbHttpsCashierUrl(request.url?.toString()) + } else { + null + } + if (upgradedCashierUrl != null) { + Log.w( + "CmbRechargeHttp", + "Upgrading CMB cashier navigation to HTTPS after HTTP 412" + ) + view?.loadUrl(upgradedCashierUrl) + super.onReceivedHttpError(view, request, errorResponse) + return + } + + Log.w("CmbRechargeHttp", "main-frame HTTP $statusCode at $pageLocation") + onLoadingChanged(false) + boundsLocator.clear() + onSuccessPageChanged(false) + onMainFrameError( + if (statusCode != null) { + "页面加载失败(HTTP $statusCode,$pageLocation),请稍后重试" + } else { + "页面加载失败,请稍后重试" + } + ) + } + super.onReceivedHttpError(view, request, errorResponse) + } + } + } +} + +private data class CmbRechargeBridgePayload( + val studentNumber: String = "", + val balance: Double = 0.0, + val paymentMethods: List = emptyList() +) + +private data class CmbRechargeBridgePaymentMethod( + val pageIndex: Int = -1, + val name: String = "" +) + +private class CmbRechargeStateBridge( + private val webView: WebView, + private val onNativeDataChanged: (CmbRechargeNativeData) -> Unit, + private val onPaymentUiStateChanged: (Boolean, String) -> Unit +) { + private val gson = Gson() + + @JavascriptInterface + fun onRechargeState(payload: String) { + webView.post { + if (!isCmbRechargeNativeEntryUrl(webView.url)) return@post + val parsed = runCatching { + gson.fromJson(payload, CmbRechargeBridgePayload::class.java) + }.getOrNull() ?: return@post + val methods = parsed.paymentMethods + .filter { it.pageIndex >= 0 && it.name.isNotBlank() } + .distinctBy { it.pageIndex } + .map { CmbRechargePaymentMethod(pageIndex = it.pageIndex, name = it.name) } + onNativeDataChanged( + CmbRechargeNativeData( + studentNumber = parsed.studentNumber, + balance = normalizeCmbRechargeBalance(parsed.balance), + paymentMethods = methods + ) + ) + } + } + + @JavascriptInterface + fun onPaymentUiState(payload: String) { + webView.post { + if (!isCmbRechargeNativeEntryUrl(webView.url)) return@post + val parsed = runCatching { + gson.fromJson(payload, CmbPaymentUiPayload::class.java) + }.getOrNull() ?: return@post + onPaymentUiStateChanged(parsed.passwordRequired, parsed.error.orEmpty()) } } } +private data class CmbPaymentUiPayload( + val passwordRequired: Boolean = false, + val error: String? = null +) + private class CmbBehaviorBridge( private val webView: WebView, private val onSubmitIntent: () -> Unit @@ -489,10 +1470,183 @@ private class CmbBehaviorBridge( private companion object { const val NATIVE_SUBMIT_DEBOUNCE_MS = 1_000L } } +private fun WebView.submitCmbRecharge( + amount: String, + paymentMethodIndex: Int, + onRejected: (String) -> Unit +) { + val amountValue = amount.toDoubleOrNull() + if ( + !isCmbRechargeNativeEntryUrl(url) || + amountValue == null || + amountValue <= 0.0 || + amountValue > 1_000.0 || + paymentMethodIndex < 0 + ) { + onRejected("充值页面状态已变化,请重试") + return + } + val script = """ + (function(){ + var root = document.querySelector('#app'); + var queue = root && root.__vue__ ? [root.__vue__] : []; + var seen = []; + var component = null; + while (queue.length) { + var current = queue.shift(); + if (!current || seen.indexOf(current) >= 0) continue; + seen.push(current); + if (typeof current.rechargeOrders === 'function' && Array.isArray(current.payType)) { + component = current; + break; + } + var children = current[String.fromCharCode(36) + 'children']; + if (children) queue = queue.concat(children); + } + if (!component || !component.payType[$paymentMethodIndex]) return 'not-ready'; + component.tranAmt = $amountValue; + component.payTypeIndex = $paymentMethodIndex; + component.cardIndex = 0; + component.charge(); + return 'submitted'; + })(); + """.trimIndent() + evaluateJavascript(script) { result -> + if (result != "\"submitted\"") { + onRejected("充值页面尚未准备好,请稍后重试") + } + } +} + +private fun WebView.submitCmbRechargePassword( + password: String, + onRejected: (String) -> Unit +) { + if ( + !isCmbRechargeNativeEntryUrl(url) || + password.length != 6 || + !password.all(Char::isDigit) + ) { + onRejected("请输入 6 位校园卡查询密码") + return + } + val escapedPassword = password + .replace("\\", "\\\\") + .replace("'", "\\'") + val script = """ + (function(){ + function visible(node) { + if (!node) return false; + var style = window.getComputedStyle(node); + return style.display !== 'none' && + style.visibility !== 'hidden' && + node.getClientRects().length > 0; + } + var sheet = Array.from(document.querySelectorAll('.van-action-sheet')).find(visible); + if (!sheet || !(sheet.innerText || '').includes('查询密码')) return 'not-ready'; + var existingDots = Array.from( + sheet.querySelectorAll('.van-password-input__security i') + ).filter(visible).length; + if (existingDots !== 0) { + return 'password-not-empty'; + } + function currentSheet() { + return Array.from(document.querySelectorAll('.van-action-sheet')).find(visible); + } + function findCurrentKey(value) { + var current = currentSheet(); + return current && Array.from(current.querySelectorAll('.keyboard td')).find(function(node) { + return (node.innerText || '').trim() === value; + }); + } + function visiblePasswordDots() { + var current = currentSheet(); + return current + ? Array.from(current.querySelectorAll('.van-password-input__security i')) + .filter(visible).length + : 0; + } + function fail(message) { + window.AhuTongRechargeBridge.onPaymentUiState(JSON.stringify({ + passwordRequired: false, + error: message + })); + } + var password = '$escapedPassword'; + if (Array.from(password).some(function(value) { return !findCurrentKey(value); })) { + return 'key-not-found'; + } + if (!findCurrentKey('确认')) return 'confirm-not-found'; + function pressAt(index) { + if (index >= password.length) { + var confirm = findCurrentKey('确认'); + if (confirm && visiblePasswordDots() === password.length) { + confirm.click(); + } else { + fail('查询密码键盘状态异常,请重试'); + } + return; + } + var key = findCurrentKey(password[index]); + if (!key) { + fail('查询密码键盘已变化,请重试'); + return; + } + key.click(); + var attempts = 0; + function waitForDot() { + if (visiblePasswordDots() >= index + 1) { + window.setTimeout(function() { pressAt(index + 1); }, 120); + } else if (attempts++ < 15) { + window.setTimeout(waitForDot, 50); + } else { + fail('查询密码键盘响应超时,请重试'); + } + } + window.setTimeout(waitForDot, 50); + } + pressAt(0); + return 'scheduled'; + })(); + """.trimIndent() + evaluateJavascript(script) { result -> + if (result != "\"scheduled\"") { + onRejected("查询密码键盘尚未准备好,请重试") + } + } +} + +private fun WebView.cancelCmbRechargePassword() { + if (!isCmbRechargeNativeEntryUrl(url)) return + evaluateJavascript( + """ + (function(){ + var sheet = Array.from(document.querySelectorAll('.van-action-sheet')) + .find(function(node){ return (node.innerText || '').includes('查询密码'); }); + var cancel = sheet && sheet.querySelector('.van-action-sheet__cancel'); + if (cancel) { + cancel.click(); + } else { + var overlays = Array.from(document.querySelectorAll('.van-overlay')); + var overlay = overlays.find(function(node) { + return window.getComputedStyle(node).display !== 'none'; + }); + if (overlay) overlay.click(); + } + })(); + """.trimIndent(), + null + ) +} + private class CmbRechargeWebViewState( val boundsLocator: CmbRechargeBoundsLocator, var requestVersion: Int = -1 -) +) { + fun dispose() { + boundsLocator.dispose() + } +} private val WebView.cmbRechargeState: CmbRechargeWebViewState? get() = tag as? CmbRechargeWebViewState @@ -638,14 +1792,20 @@ internal fun isCmbRechargeSuccessUrl(url: String?): Boolean { 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) && + host == "epay92.ahu.edu.cn" && path == "/cashier-mobile/chargeresult" } +internal fun shouldConfirmCmbRechargeSuccess( + url: String?, + verifiedReturnBounds: CmbRechargeNormalizedBounds? +): Boolean = verifiedReturnBounds != null && isCmbRechargeSuccessUrl(url) + internal fun isCmbRechargeStyleTarget(url: String?): Boolean { if (url.isNullOrBlank()) return false val uri = runCatching { URI(url) }.getOrNull() ?: return false + if (uri.scheme.orEmpty().lowercase() != "https" || uri.port !in setOf(-1, 443)) return false val host = uri.host.orEmpty().lowercase() val path = uri.path.orEmpty().lowercase() return when (host) { @@ -655,6 +1815,64 @@ internal fun isCmbRechargeStyleTarget(url: String?): Boolean { } } +internal fun isCmbRechargeNativeEntryUrl(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" && + uri.port in setOf(-1, 443) && + host == "epay92.ahu.edu.cn" && + path == "/cashier-mobile/charge" +} + +internal fun isCmbRechargeInsecureEntryUrl(url: String?): Boolean { + if (url.isNullOrBlank()) return false + val uri = runCatching { URI(url) }.getOrNull() ?: return false + return uri.scheme.orEmpty().equals("http", ignoreCase = true) && + uri.port in setOf(-1, 80) && + uri.host.orEmpty().equals("epay92.ahu.edu.cn", ignoreCase = true) && + uri.path.orEmpty().trimEnd('/').equals( + "/cashier-mobile/charge", + ignoreCase = true + ) +} + +internal fun isCmbRechargeHiddenFlowUrl(url: String?): Boolean { + if (isCmbRechargeNativeEntryUrl(url)) return true + 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 == "ycard.ahu.edu.cn" && + uri.port in setOf(-1, 443) && + path == "/berserker-base/redirect" +} + +internal fun shouldRevealCmbRechargeWebContent( + url: String?, + revealAllowed: Boolean +): Boolean = revealAllowed && + !url.isNullOrBlank() && + !isCmbRechargeHiddenFlowUrl(url) && + !isCmbRechargeSuccessUrl(url) + +private fun WebView.updateCmbRechargeWebViewVisibility(isVisible: Boolean) { + visibility = if (isVisible) View.VISIBLE else View.INVISIBLE + isEnabled = isVisible + importantForAccessibility = if (isVisible) { + View.IMPORTANT_FOR_ACCESSIBILITY_AUTO + } else { + View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } +} + +internal fun normalizeCmbRechargeBalance(balanceInCents: Double): Double = + if (balanceInCents.isFinite()) balanceInCents / 100.0 else 0.0 + private fun buildCmbRechargeEntryUrl(token: String): String { return Uri.Builder() .scheme("https") @@ -670,13 +1888,73 @@ private fun buildCmbRechargeEntryUrl(token: String): String { .toString() } -private fun isInternalCmbRechargeUrl(url: Uri): Boolean { - val host = url.host.orEmpty().lowercase() - return host == "ahu.edu.cn" || host.endsWith(".ahu.edu.cn") +internal fun isCmbLoginRedirectUrl(url: String?): Boolean { + val uri = url?.let { runCatching { URI(it) }.getOrNull() } ?: return false + return uri.scheme.equals("https", ignoreCase = true) && + uri.host.equals("epay92.ahu.edu.cn", ignoreCase = true) && + uri.port in setOf(-1, 443) && + uri.path.orEmpty().trimEnd('/').equals( + "/member/login/redirect", + ignoreCase = true + ) +} + +internal fun buildCmbHttpsCashierUrl(url: String?): String? { + val uri = url?.let { runCatching { URI(it) }.getOrNull() } ?: return null + val scheme = uri.scheme.orEmpty().lowercase() + val trustedPort = scheme == "http" && uri.port in setOf(-1, 80) + if ( + !trustedPort || + !uri.host.equals("epay92.ahu.edu.cn", ignoreCase = true) || + !uri.path.orEmpty().trimEnd('/').equals( + "/cashier-mobile/cashier", + ignoreCase = true + ) + ) { + return null + } + + return URI( + "https", + uri.userInfo, + uri.host, + -1, + uri.path, + uri.query, + uri.fragment + ).toString() +} + +private fun safeCmbPageLocation(url: String?): String { + val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return "未知页面" + val scheme = uri.scheme.orEmpty().lowercase() + val host = uri.host.orEmpty().lowercase() + val path = uri.encodedPath.orEmpty().ifBlank { "/" } + return "$scheme://$host$path" +} + +internal fun isCmbRechargeAllowedMainFrameUrl(url: String?): Boolean { + val uri = url?.let { runCatching { URI(it) }.getOrNull() } ?: return false + if (uri.scheme.orEmpty().lowercase() != "https" || uri.port !in setOf(-1, 443)) return false + val host = uri.host.orEmpty().lowercase() + val path = uri.path.orEmpty() + return when (host) { + "ycard.ahu.edu.cn" -> + path == "/berserker-base/redirect" || + path == "/charge-app" || + path.startsWith("/charge-app/") + "epay92.ahu.edu.cn" -> + path == "/member/login/redirect" || + path == "/cashier-mobile" || + path.startsWith("/cashier-mobile/") + else -> false + } } -private fun isAuditedCmbSubmitPage(url: Uri): Boolean = isInternalCmbRechargeUrl(url) && - (url.path.orEmpty().contains("/cashier-mobile/charge") || url.path.orEmpty().contains("/charge-app")) +private fun isAuditedCmbSubmitPage(url: Uri): Boolean = + isCmbRechargeAllowedMainFrameUrl(url.toString()) && + (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() diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbRechargeNativePanel.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbRechargeNativePanel.kt new file mode 100644 index 00000000..7d27bca0 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbRechargeNativePanel.kt @@ -0,0 +1,474 @@ +package com.ahu.ahutong.ui.screen.main + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import com.ahu.ahutong.ui.components.AppCircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +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.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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +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.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.ahu.ahutong.ui.component.SecurePaymentPasswordDialog +import com.ahu.ahutong.ui.components.AppSelectField +import com.ahu.ahutong.ui.components.AppSelectOption +import java.util.Locale + +internal data class CmbRechargeNativeData( + val studentNumber: String, + val balance: Double, + val paymentMethods: List +) + +internal data class CmbRechargePaymentMethod( + val pageIndex: Int, + val name: String +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun CmbRechargeNativePanel( + data: CmbRechargeNativeData?, + errorMessage: String?, + isSubmitting: Boolean, + onRetry: () -> Unit, + onManagePaymentMethods: () -> Unit, + onSubmit: (amount: String, paymentMethodIndex: Int) -> Unit +) { + var amount by remember { mutableStateOf("") } + var selectedPaymentMethodIndex by remember { mutableIntStateOf(-1) } + val focusManager = LocalFocusManager.current + + LaunchedEffect(data?.paymentMethods) { + val methods = data?.paymentMethods.orEmpty() + if (methods.none { it.pageIndex == selectedPaymentMethodIndex }) { + selectedPaymentMethodIndex = methods.firstOrNull()?.pageIndex ?: -1 + } + } + + val amountValue = amount.toDoubleOrNull() + val amountError = when { + amount.isBlank() -> null + amountValue == null || amountValue <= 0.0 -> "请输入有效的充值金额" + amountValue > CMB_RECHARGE_MAX_AMOUNT -> "单次充值金额不能超过 1000 元" + else -> null + } + val selectedMethod = data?.paymentMethods + ?.firstOrNull { it.pageIndex == selectedPaymentMethodIndex } + val canSubmit = data != null && + selectedMethod != null && + amountValue != null && + amountValue > 0.0 && + amountValue <= CMB_RECHARGE_MAX_AMOUNT && + !isSubmitting + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + when { + data == null && errorMessage != null -> NativeRechargeLoadState( + title = "充值信息加载失败", + message = errorMessage, + onRetry = onRetry + ) + + data == null -> NativeRechargeLoadState( + title = "正在加载充值信息", + message = "正在安全连接校园卡充值服务,请稍候。" + ) + + else -> { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentPadding = PaddingValues( + start = 16.dp, + top = 16.dp, + end = 16.dp, + bottom = 12.dp + ), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (errorMessage != null) { + item { + NativeRechargeMessageCard( + title = "本次充值未完成", + message = errorMessage, + actionText = "重新加载", + onAction = onRetry + ) + } + } + + item { NativeRechargeAccountCard(data = data) } + + item { + NativeRechargeSection(title = "充值金额") { + OutlinedTextField( + value = amount, + onValueChange = { value -> + if (value.matches(Regex("^\\d{0,4}(\\.\\d{0,2})?$"))) { + amount = value + } + }, + modifier = Modifier.fillMaxWidth(), + label = { Text("充值金额") }, + prefix = { Text("¥ ") }, + placeholder = { Text("请输入金额") }, + supportingText = amountError?.let { message -> { Text(message) } }, + isError = amountError != null, + singleLine = true, + enabled = !isSubmitting, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { focusManager.clearFocus() } + ) + ) + + CMB_RECHARGE_PRESETS.chunked(2).forEach { presets -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + presets.forEach { preset -> + OutlinedButton( + onClick = { + amount = preset + focusManager.clearFocus() + }, + modifier = Modifier + .weight(1f) + .height(48.dp), + enabled = !isSubmitting, + contentPadding = PaddingValues(horizontal = 8.dp) + ) { + Text("¥$preset") + } + } + } + } + } + } + + item { + NativeRechargeSection(title = "支付方式") { + if (data.paymentMethods.isEmpty()) { + Text( + text = "尚未绑定可用的免密支付方式,请先前往学校支付页面完成绑定。", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + TextButton( + onClick = onManagePaymentMethods, + enabled = !isSubmitting + ) { + Text("管理免密支付方式") + } + } else { + AppSelectField( + label = "扣款方式", + selected = selectedMethod?.pageIndex, + options = data.paymentMethods.map { method -> + AppSelectOption(method.pageIndex, method.name) + }, + onSelected = { selectedPaymentMethodIndex = it }, + enabled = !isSubmitting + ) + TextButton( + onClick = onManagePaymentMethods, + enabled = !isSubmitting + ) { + Text("管理支付方式") + } + } + } + } + + item { + Text( + text = "充值金额将先进入过渡余额,刷卡后转入校园卡。银行卡授权与验证码只在官方页面完成;如需校园卡查询密码,本页会将其安全转交当前校方充值页面。", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + } + } + + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 3.dp + ) { + Button( + onClick = { + focusManager.clearFocus() + onSubmit(amount, selectedPaymentMethodIndex) + }, + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .imePadding() + .padding(horizontal = 16.dp, vertical = 12.dp) + .height(56.dp), + enabled = canSubmit + ) { + if (isSubmitting) { + AppCircularProgressIndicator( + size = 24.dp, + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Text("确认充值") + } + } + } + } + } + } +} + +@Composable +private fun ColumnScope.NativeRechargeLoadState( + title: String, + message: String, + onRetry: (() -> Unit)? = null +) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (onRetry == null) { + AppCircularProgressIndicator(size = 36.dp) + } + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + Text( + text = message, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + onRetry?.let { retry -> + Button(onClick = retry) { Text("重试") } + } + } + } +} + +@Composable +private fun NativeRechargeAccountCard(data: CmbRechargeNativeData) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 1.dp + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "校园卡当前余额", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelLarge + ) + Text( + text = String.format(Locale.CHINA, "¥ %.2f", data.balance), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.headlineMedium + ) + NativeRechargeInfoRow( + label = "学工号", + value = data.studentNumber.ifBlank { "未获取到" } + ) + } + } +} + +@Composable +private fun NativeRechargeSection( + title: String, + content: @Composable ColumnScope.() -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + content() + } + } +} + +@Composable +private fun NativeRechargeInfoRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + text = value, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun NativeRechargeMessageCard( + title: String, + message: String, + actionText: String, + onAction: () -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.errorContainer + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = title, + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.titleMedium + ) + Text(message, color = MaterialTheme.colorScheme.onErrorContainer) + TextButton(onClick = onAction) { Text(actionText) } + } + } +} + +private const val CMB_RECHARGE_MAX_AMOUNT = 1_000.0 +private val CMB_RECHARGE_PRESETS = listOf("50", "100", "200", "500") + +@Composable +internal fun CmbRechargeQueryPasswordDialog( + onCancel: () -> Unit, + onConfirm: (String) -> Unit +) { + var password by remember { mutableStateOf("") } + + SecurePaymentPasswordDialog( + password = password, + onPasswordChange = { password = it }, + title = "输入校园卡查询密码", + onDismissRequest = onCancel, + onConfirm = onConfirm + ) +} + +@Composable +internal fun CmbRechargeNativeSuccessPanel( + onDone: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .navigationBarsPadding() + .padding(24.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = "充值成功", + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + Text( + text = "订单已由招商银行免密支付完成,余额将在刷卡后转入校园卡。", + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + Button( + onClick = onDone, + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + Text("返回校园卡") + } + } +} 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..f3f98bff 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,433 @@ 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.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 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 campusOptions = remember(campusList) { + campusList.map { AppSelectOption(it, it.name) } } - 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 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 = 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) + Text("最近使用的房间") + } + } - Row( - verticalAlignment = Alignment.CenterVertically, + val loadingSelector = when { + !isLoading -> null + 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) + ) { + 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 = "请先选择校区", + enabled = 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 + ) + } + + roomInfo?.takeIf(String::isNotBlank)?.let { info -> + Column( modifier = Modifier - .clickable { openBuildingMenu() } + .padding(horizontal = 16.dp) + .fillMaxWidth() + .appLiquidGlassSurface( + shape = AppComponentTokens.CardShape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainer, + level = LiquidGlassSurfaceLevel.Panel + ) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = selectedBuilding?.name ?: "请选择楼栋" + text = "房间信息", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold ) - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = "展开楼栋列表" + Text( + text = info.replace(",", "\n"), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge ) - - 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 - } - ) - } - } } } - Row( + Column( modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - .clickable { openFloorMenu() }, - horizontalArrangement = Arrangement.SpaceBetween, + .padding(horizontal = 16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - 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 - } - ) + 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(), + enabled = !isLoading, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) } - Row( + Column( modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - .clickable { openRoomMenu() }, - horizontalArrangement = Arrangement.SpaceBetween, + .padding(horizontal = 16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - Text(text = "选择房间", style = MaterialTheme.typography.titleMedium) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { openRoomMenu() }, - ) { - Text( - text = selectedRoom?.name ?: "请选择房间" + 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 ) - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = "展开房间列表" + is PayState.Failed -> Text( + text = "缴费失败:${state.message}", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium ) + } + AppButton( + onClick = { showPasswordDialog = true }, + modifier = Modifier.fillMaxWidth(), + enabled = canPay + ) { + Text(if (payState is PayState.InProgress) "正在支付" else "确认缴费") + } + } + } - 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 (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 位数字" } } + ) + } +} - if (historyOptions.size == 2) { - Column( +@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 - .padding(horizontal = 16.dp) - .padding(bottom = 8.dp) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically ) { - historyOptions.forEach { item -> - Row( + AppButton( + onClick = { + viewModel.selectHistory(item) + onRoomSelected() + }, + modifier = Modifier.weight(1f), + variant = AppButtonVariant.Secondary + ) { + Column( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End + verticalArrangement = Arrangement.spacedBy(2.dp) ) { + Text(item.label, style = MaterialTheme.typography.titleSmall) 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 + 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 ) } } + AppButton( + onClick = { viewModel.deleteHistory(item) }, + variant = AppButtonVariant.Destructive + ) { + Text("删除") + } } } - - 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, - ) { - Text(text = "信息", style = MaterialTheme.typography.titleMedium) - Text(text = roomInfo?.replace(",", "\n") ?: "") - } } + } +} - 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 - ) - } - Row( +@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(), - horizontalArrangement = Arrangement.End - ) { - Box( + placeholder = placeholder, + enabled = enabled, + miuixStandalone = true + ) + if (loading) { + AppCircularProgressIndicator( 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 (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 - ) - } - - 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 - ) - } - } - - is PayState.Succeeded -> { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(24.dp) - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = 100.n1 - ) - Text( - text = "支付成功! 订单号:${(payState.value as PayState.Succeeded).message}", - modifier = Modifier - .padding(4.dp) - .clickable { - - }, - color = 100.n1, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.headlineSmall - ) - } - } - - 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 - ) - } - } - } - } - } - 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) - } - } + .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/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..d176e3fb 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 @@ -186,36 +191,22 @@ 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() + .filter(ElectricityDepositHistoryItem::confirmedByPayment) + .sortedByDescending(ElectricityDepositHistoryItem::updatedAt) + .take(MAX_ROOM_HISTORY) + _historyOptions.value = history + val lastSelection = AHUCache.getRoomSelection() + ?.takeIf(::isCompleteSelection) + ?: history.firstOrNull { isCompleteSelection(it.selection) }?.selection + if (lastSelection != null) { + Log.d("ElectricityDepositViewModel", "选择从缓存恢复") + loadAndRestoreSelection(lastSelection) } 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() - } + fetchCampuses() } viewModelScope.launch { _presetCandidates.value = behaviorRuntime.rankLocalPresets(SemanticDomain.ELECTRICITY) @@ -223,7 +214,8 @@ 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 { @@ -231,19 +223,51 @@ class ElectricityDepositViewModel @Inject constructor( _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 + val (campuses, buildings, floors, rooms) = coroutineScope { + val campusesRequest = async { getCampus() } + val buildingsRequest = async { getBuildings() } + val floorsRequest = async { getFloor() } + val roomsRequest = async { getRoom() } + listOf( + campusesRequest.await(), + buildingsRequest.await(), + floorsRequest.await(), + roomsRequest.await() + ) + } + @Suppress("UNCHECKED_CAST") + (campuses.data as? List)?.let { _campusList.value = it } + @Suppress("UNCHECKED_CAST") + (buildings.data as? List)?.let { _buildingsList.value = it } + @Suppress("UNCHECKED_CAST") + (floors.data as? List)?.let { _floorsList.value = it } + @Suppress("UNCHECKED_CAST") + (rooms.data as? List)?.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 +278,20 @@ 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 onCampusSelected(campus: CampusDataItem) { + selectionLoadJob?.cancel() _selectedCampus.value = campus _buildingsList.value = emptyList() _selectedBuilding.value = null @@ -271,6 +304,7 @@ class ElectricityDepositViewModel @Inject constructor( } fun onBuildingSelected(building: CampusDataItem) { + selectionLoadJob?.cancel() _selectedBuilding.value = building _floorsList.value = emptyList() _selectedFloor.value = null @@ -281,6 +315,7 @@ class ElectricityDepositViewModel @Inject constructor( } fun onfloorSelected(floor: CampusDataItem) { + selectionLoadJob?.cancel() _selectedFloor.value = floor _roomsList.value = emptyList() _selectedRoom.value = null @@ -289,26 +324,46 @@ class ElectricityDepositViewModel @Inject constructor( } fun onRoomSelected(room: CampusDataItem) { + selectionLoadJob?.cancel() _selectedRoom.value = room _roomInfo.value = null fetchRoomInfo() } + fun retry() { + when { + _selectedCampus.value == null -> fetchCampuses() + _selectedBuilding.value == null -> fetchBuildings() + _selectedFloor.value == null -> fetchFloor() + _selectedRoom.value == null -> fetchRoom() + else -> fetchRoomInfo() + } + } + private fun fetchCampuses() { - viewModelScope.launch { + selectionLoadJob = viewModelScope.launch { _isLoading.value = true _errorMessage.value = null try { val response = getCampus() if (response.code == 0 && response.data != null) { - _campusList.value = response.data!! + val items = response.data!! + _campusList.value = items + if (_selectedCampus.value == null) { + items.firstOrNull()?.let { first -> + _selectedCampus.value = first + fetchBuildings() + } + } } else { _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 + } } } } @@ -321,7 +376,7 @@ class ElectricityDepositViewModel @Inject constructor( .add("level", "0") .build() try { - val res = YcardApi.API.getFeeItemThirdData(formBody) + val res = YcardApi.authorizedCall { getFeeItemThirdData(formBody) } Log.d("ElectricityDepositViewModel", "getCampus响应码: ${res.code()}") val responseBody = res.body()?.string() if (res.isSuccessful) { @@ -361,20 +416,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,8 +445,6 @@ class ElectricityDepositViewModel @Inject constructor( return responseWrapper } - _isLoading.value = true - _errorMessage.value = null val formBody = FormBody.Builder() .add("feeitemid", "488") .add("type", "select") @@ -397,7 +453,7 @@ class ElectricityDepositViewModel @Inject constructor( .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 +478,6 @@ class ElectricityDepositViewModel @Inject constructor( } catch (e: Exception) { responseWrapper.code = -1 responseWrapper.msg = "发生未知错误: ${e.message}" - } finally { - _isLoading.value = false } return responseWrapper } @@ -434,20 +488,23 @@ 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 + } } } } @@ -474,7 +531,7 @@ class ElectricityDepositViewModel @Inject constructor( .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,20 +566,23 @@ 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 + } } } } @@ -555,7 +615,7 @@ class ElectricityDepositViewModel @Inject constructor( .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 +650,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() @@ -719,7 +780,7 @@ class ElectricityDepositViewModel @Inject constructor( .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) { @@ -783,7 +844,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 +885,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 +979,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() @@ -952,18 +1013,16 @@ class ElectricityDepositViewModel @Inject constructor( floor = _selectedFloor.value, room = _selectedRoom.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,6 +1077,45 @@ class ElectricityDepositViewModel @Inject constructor( return builder.build() } + private fun isCompleteSelection(selection: RoomSelectionInfo): Boolean { + return 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 + ), + confirmedByPayment: Boolean = false + ) { + if (!isCompleteSelection(selection)) return + + saveRoomSelection(selection) + if (!confirmedByPayment) return + val label = normalizeLabel( + _fullRoomDetails.value?.data?.roomName ?: selection.room?.name.orEmpty() + ) + if (label.isBlank()) return + + 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.campus?.value, @@ -1035,4 +1133,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/CmbRechargePageStyleTest.kt b/app/src/test/java/com/ahu/ahutong/ui/screen/main/CmbRechargePageStyleTest.kt index 443544f9..bed23aef 100644 --- 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 @@ -2,6 +2,7 @@ package com.ahu.ahutong.ui.screen.main import kotlin.test.Test import kotlin.test.assertContains +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -112,7 +113,7 @@ class CmbRechargePageStyleTest { "https://epay92.ahu.edu.cn/cashier-mobile/charge?disable=1" ) ) - assertTrue(isCmbRechargeStyleTarget("http://epay92.ahu.edu.cn/cashier-mobile/")) + assertFalse(isCmbRechargeStyleTarget("http://epay92.ahu.edu.cn/cashier-mobile/")) assertTrue( isCmbRechargeStyleTarget( "https://epay92.ahu.edu.cn/cashier-mobile/chargeResult" @@ -136,6 +137,11 @@ class CmbRechargePageStyleTest { ) ) assertFalse(isCmbRechargeStyleTarget("https://other.ahu.edu.cn/charge-app/")) + assertFalse( + isCmbRechargeStyleTarget( + "https://epay92.ahu.edu.cn:444/cashier-mobile/charge" + ) + ) } @Test @@ -150,6 +156,11 @@ class CmbRechargePageStyleTest { "https://epay92.ahu.edu.cn/cashier-mobile/chargeResult/?order=1" ) ) + assertFalse( + isCmbRechargeSuccessUrl( + "http://epay92.ahu.edu.cn/cashier-mobile/chargeResult" + ) + ) assertFalse( isCmbRechargeSuccessUrl( "https://epay92.ahu.edu.cn/cashier-mobile/charge" @@ -162,7 +173,7 @@ class CmbRechargePageStyleTest { ) assertFalse( isCmbRechargeSuccessUrl( - "http://epay92.ahu.edu.cn/cashier-mobile/chargeResult" + "http://epay92.ahu.edu.cn:8080/cashier-mobile/chargeResult" ) ) assertFalse( @@ -183,11 +194,308 @@ class CmbRechargePageStyleTest { } + @Test + fun nativeEntryUrlIsStrictlyScoped() { + assertFalse( + isCmbRechargeNativeEntryUrl( + "http://epay92.ahu.edu.cn/cashier-mobile/charge?disable=1" + ) + ) + assertTrue( + isCmbRechargeInsecureEntryUrl( + "http://epay92.ahu.edu.cn/cashier-mobile/charge?disable=1" + ) + ) + assertFalse( + isCmbRechargeInsecureEntryUrl( + "http://epay92.ahu.edu.cn.evil.example/cashier-mobile/charge" + ) + ) + assertTrue( + isCmbRechargeNativeEntryUrl( + "https://epay92.ahu.edu.cn/cashier-mobile/charge/" + ) + ) + assertFalse( + isCmbRechargeNativeEntryUrl( + "https://epay92.ahu.edu.cn.evil.example/cashier-mobile/charge" + ) + ) + assertFalse( + isCmbRechargeNativeEntryUrl( + "https://epay92.ahu.edu.cn/cashier-mobile/chargeResult" + ) + ) + assertFalse( + isCmbRechargeNativeEntryUrl( + "https://epay92.ahu.edu.cn:444/cashier-mobile/charge" + ) + ) + } + + @Test + fun hiddenFlowUrlIncludesOnlyTheTrustedBootstrapAndNativeEntry() { + assertTrue( + isCmbRechargeHiddenFlowUrl( + "https://ycard.ahu.edu.cn/berserker-base/redirect?appId=253" + ) + ) + assertTrue( + isCmbRechargeHiddenFlowUrl( + "https://epay92.ahu.edu.cn/cashier-mobile/charge?disable=1" + ) + ) + assertFalse( + isCmbRechargeHiddenFlowUrl( + "https://ycard.ahu.edu.cn/berserker-base/redirect/other" + ) + ) + assertFalse( + isCmbRechargeHiddenFlowUrl( + "https://ycard.ahu.edu.cn.evil.example/berserker-base/redirect" + ) + ) + } + + @Test + fun webContentIsRevealedOnlyAfterAnExplicitNativeAction() { + val bootstrapUrl = + "https://ycard.ahu.edu.cn/berserker-base/redirect?appId=253" + val nativeEntryUrl = + "https://epay92.ahu.edu.cn/cashier-mobile/charge?disable=1" + val officialPaymentUrl = + "https://epay92.ahu.edu.cn/cashier-mobile/pay" + val successUrl = + "https://epay92.ahu.edu.cn/cashier-mobile/chargeResult" + + assertFalse(shouldRevealCmbRechargeWebContent(null, revealAllowed = true)) + assertFalse(shouldRevealCmbRechargeWebContent(bootstrapUrl, revealAllowed = true)) + assertFalse(shouldRevealCmbRechargeWebContent(nativeEntryUrl, revealAllowed = true)) + assertFalse(shouldRevealCmbRechargeWebContent(officialPaymentUrl, revealAllowed = false)) + assertTrue(shouldRevealCmbRechargeWebContent(officialPaymentUrl, revealAllowed = true)) + assertFalse(shouldRevealCmbRechargeWebContent(successUrl, revealAllowed = true)) + } + + @Test + fun nativeBalanceConvertsServerCentsToYuan() { + assertEquals(7.79, normalizeCmbRechargeBalance(779.0), 0.0001) + assertEquals(0.0, normalizeCmbRechargeBalance(Double.NaN), 0.0001) + } + + @Test + fun preloadedSessionExpiresAtThreeMinutes() { + val readyAt = 10_000L + + assertTrue(isCmbRechargeSessionFresh(readyAt, readyAt)) + assertTrue( + isCmbRechargeSessionFresh( + readyAt, + readyAt + CMB_RECHARGE_PRELOAD_VALIDITY_MS - 1L + ) + ) + assertFalse( + isCmbRechargeSessionFresh( + readyAt, + readyAt + CMB_RECHARGE_PRELOAD_VALIDITY_MS + ) + ) + assertFalse(isCmbRechargeSessionFresh(0L, readyAt)) + assertFalse(isCmbRechargeSessionFresh(readyAt, readyAt - 1L)) + } + + @Test + fun rechargeCannotDispatchBeforePasswordIsProvided() { + assertFalse( + canDispatchCmbRecharge( + amount = "100", + password = null, + hasFreshSession = true + ) + ) + assertFalse( + canDispatchCmbRecharge( + amount = "100", + password = "", + hasFreshSession = true + ) + ) + assertFalse( + canDispatchCmbRecharge( + amount = "100", + password = "123456", + hasFreshSession = false + ) + ) + assertTrue( + canDispatchCmbRecharge( + amount = "100", + password = "123456", + hasFreshSession = true + ) + ) + + assertTrue( + canDispatchCmbPassword( + password = "123456", + dispatchInProgress = false, + hasWebView = true + ) + ) + assertFalse( + canDispatchCmbPassword( + password = null, + dispatchInProgress = false, + hasWebView = true + ) + ) + assertFalse( + canDispatchCmbPassword( + password = "123456", + dispatchInProgress = true, + hasWebView = true + ) + ) + } + + @Test + fun expiredSessionRecoveryRequiresCompleteSubmissionAndRunsOnlyOnce() { + assertTrue(isCmbSessionExpiredMessage("登录已失效,请重新登录")) + assertTrue(isCmbSessionExpiredMessage("当前会话已过期")) + assertFalse(isCmbSessionExpiredMessage("查询密码错误")) + + assertTrue( + shouldRecoverCmbSession( + message = "登录失效", + recoveryAttempted = false, + amount = "100", + password = "123456" + ) + ) + assertFalse( + shouldRecoverCmbSession( + message = "登录失效", + recoveryAttempted = true, + amount = "100", + password = "123456" + ) + ) + assertFalse( + shouldRecoverCmbSession( + message = "登录失效", + recoveryAttempted = false, + amount = "100", + password = null + ) + ) + assertFalse( + shouldRecoverCmbSession( + message = "查询密码错误", + recoveryAttempted = false, + amount = "100", + password = "123456" + ) + ) + } + + @Test + fun loginRedirectAndHttpsUpgradeAreStrictlyScoped() { + assertTrue( + isCmbLoginRedirectUrl( + "https://epay92.ahu.edu.cn/member/login/redirect?ticket=hidden" + ) + ) + assertFalse( + isCmbLoginRedirectUrl( + "https://epay92.ahu.edu.cn/cashier-mobile/charge" + ) + ) + assertFalse( + isCmbLoginRedirectUrl( + "https://epay92.ahu.edu.cn.evil.example/member/login/redirect" + ) + ) + assertFalse( + isCmbLoginRedirectUrl( + "http://epay92.ahu.edu.cn/member/login/redirect" + ) + ) + + assertEquals( + "https://epay92.ahu.edu.cn/cashier-mobile/cashier", + buildCmbHttpsCashierUrl( + "http://epay92.ahu.edu.cn/cashier-mobile/cashier" + ) + ) + assertEquals( + "https://epay92.ahu.edu.cn/cashier-mobile/cashier?ticket=hidden&embed=true", + buildCmbHttpsCashierUrl( + "http://epay92.ahu.edu.cn/cashier-mobile/cashier?ticket=hidden&embed=true" + ) + ) + assertNull( + buildCmbHttpsCashierUrl( + "https://epay92.ahu.edu.cn/cashier-mobile/cashier" + ) + ) + assertNull( + buildCmbHttpsCashierUrl( + "http://epay92.ahu.edu.cn.evil.example/cashier-mobile/cashier" + ) + ) + } + + @Test + fun mainFrameAllowlistIncludesTheCmbLoginRedirectOnlyOnTheTrustedOrigin() { + assertTrue( + isCmbRechargeAllowedMainFrameUrl( + "https://epay92.ahu.edu.cn/member/login/redirect?ticket=hidden" + ) + ) + assertTrue( + isCmbRechargeAllowedMainFrameUrl( + "https://epay92.ahu.edu.cn/cashier-mobile/charge" + ) + ) + assertFalse( + isCmbRechargeAllowedMainFrameUrl( + "http://epay92.ahu.edu.cn/member/login/redirect" + ) + ) + assertFalse( + isCmbRechargeAllowedMainFrameUrl( + "https://epay92.ahu.edu.cn.evil.example/member/login/redirect" + ) + ) + assertFalse( + isCmbRechargeAllowedMainFrameUrl( + "https://epay92.ahu.edu.cn/member/login/redirect/other" + ) + ) + } + @Test fun normalizedOverlayBoundsAreParsedAndValidated() { val bounds = assertNotNull( parseCmbRechargeNormalizedBounds("[0.05,0.72,0.90,0.08]") ) + assertTrue( + shouldConfirmCmbRechargeSuccess( + "https://epay92.ahu.edu.cn/cashier-mobile/chargeResult", + bounds + ) + ) + assertFalse( + shouldConfirmCmbRechargeSuccess( + "https://epay92.ahu.edu.cn/cashier-mobile/chargeResult", + null + ) + ) + assertFalse( + shouldConfirmCmbRechargeSuccess( + "http://epay92.ahu.edu.cn/cashier-mobile/chargeResult", + bounds + ) + ) assertTrue(bounds.left in 0.049f..0.051f) assertTrue(bounds.top in 0.719f..0.721f) assertTrue(bounds.width in 0.899f..0.901f) 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") + } + } } } From c10f698bd001249fe9c2ea3c7a380b4bbf1d00fd Mon Sep 17 00:00:00 2001 From: InChange-Jiang <316875401+InChange-Jiang@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:27:07 +0800 Subject: [PATCH 06/29] =?UTF-8?q?feat:=20RadiantUI=20=E5=88=86=E6=94=AF?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=9C=AC=E5=9C=B0=E5=BC=80=E5=8F=91=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=EF=BC=88=E5=AD=A6=E4=B9=A0=E9=80=9A=E6=97=A5=E5=8E=86?= =?UTF-8?q?=E4=B8=8E=E6=9D=83=E9=99=90=E5=9B=BE=E6=A0=87=E3=80=81=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=208=20=E9=A1=B9=20iconpark=20=E5=9B=BE?= =?UTF-8?q?=E6=A0=87=E3=80=81=E6=97=A5=E7=A8=8B/=E8=AF=BE=E8=A1=A8?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E7=AD=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + .../java/com/ahu/ahutong/data/dao/AHUCache.kt | 8 + .../ahutong/data/dao/PreferencesManager.kt | 14 + .../com/ahu/ahutong/data/model/UiStyle.kt | 23 + .../ahu/ahutong/ui/components/GlassCard.kt | 68 ++ .../ahutong/ui/components/LiquidBottomTabs.kt | 19 + .../ahu/ahutong/ui/components/LiquidGlass.kt | 104 ++ .../ahu/ahutong/ui/components/LocalUiStyle.kt | 15 + .../ui/components/PayCapsuleConfirmButton.kt | 144 +++ .../ui/components/SecondaryPageHeader.kt | 152 +++ .../ui/components/SecondaryPageScaffold.kt | 272 +++++ .../ui/components/SettingsComponents.kt | 110 +- .../com/ahu/ahutong/ui/screen/BottomNavBar.kt | 192 ++- .../java/com/ahu/ahutong/ui/screen/Main.kt | 26 +- .../com/ahu/ahutong/ui/screen/Settings.kt | 26 +- .../ahutong/ui/screen/main/BathroomDeposit.kt | 427 ++++++- .../ui/screen/main/CardBalanceDeposit.kt | 884 ++++++++++---- .../ui/screen/main/ElectricityDeposit.kt | 970 +++++++++------ .../ahu/ahutong/ui/screen/main/Evaluation.kt | 227 +++- .../com/ahu/ahutong/ui/screen/main/Exam.kt | 403 +++--- .../ahutong/ui/screen/main/FreeClassroom.kt | 45 +- .../com/ahu/ahutong/ui/screen/main/Grade.kt | 284 ++++- .../com/ahu/ahutong/ui/screen/main/Home.kt | 132 +- .../ahu/ahutong/ui/screen/main/LostFound.kt | 71 +- .../ui/screen/main/MoreWidgetsScreen.kt | 104 ++ .../ahutong/ui/screen/main/NetworkRecharge.kt | 480 ++++++-- .../ahu/ahutong/ui/screen/main/PhoneBook.kt | 230 ++-- .../ahu/ahutong/ui/screen/main/Repository.kt | 183 ++- .../ahu/ahutong/ui/screen/main/Schedule.kt | 1087 ++++++++++++----- .../com/ahu/ahutong/ui/screen/main/Tools.kt | 8 +- .../com/ahu/ahutong/ui/screen/main/Weather.kt | 161 +-- .../ahutong/ui/screen/main/home/AtAGlance.kt | 105 +- .../ahutong/ui/screen/main/home/CampusCard.kt | 157 ++- .../ui/screen/main/home/HomeDateRow.kt | 39 + .../ui/screen/main/home/HomeWidgetEditor.kt | 293 ++++- .../ui/screen/main/home/HomeWidgetRegistry.kt | 28 +- .../ui/screen/main/schedule/CourseCard.kt | 51 +- .../ahutong/ui/screen/settings/Preferences.kt | 21 +- .../xuexiaotong/XuexiaotongDockState.kt | 18 + .../screen/xuexiaotong/XuexiaotongScreen.kt | 900 ++++++++++---- .../ahutong/ui/state/PreferencesViewModel.kt | 114 +- .../java/com/ahu/ahutong/ui/theme/AHUTheme.kt | 6 +- app/src/main/res/drawable/ic_add.xml | 27 + app/src/main/res/drawable/ic_aiming.xml | 42 + app/src/main/res/drawable/ic_announcement.xml | 35 + app/src/main/res/drawable/ic_bathroom_pay.xml | 57 +- app/src/main/res/drawable/ic_check_one.xml | 20 + app/src/main/res/drawable/ic_clear.xml | 48 + app/src/main/res/drawable/ic_config.xml | 19 + app/src/main/res/drawable/ic_download.xml | 42 + .../main/res/drawable/ic_electricity_pay.xml | 13 +- app/src/main/res/drawable/ic_evaluation.xml | 34 +- app/src/main/res/drawable/ic_exam.xml | 26 +- app/src/main/res/drawable/ic_filter.xml | 13 + app/src/main/res/drawable/ic_find.xml | 36 + app/src/main/res/drawable/ic_grade.xml | 21 +- app/src/main/res/drawable/ic_income.xml | 56 + app/src/main/res/drawable/ic_log.xml | 34 + app/src/main/res/drawable/ic_logout.xml | 28 + app/src/main/res/drawable/ic_more_all.xml | 31 + .../main/res/drawable/ic_nav_degree_hat.xml | 26 + app/src/main/res/drawable/ic_nav_home.xml | 26 + app/src/main/res/drawable/ic_nav_plan.xml | 37 + app/src/main/res/drawable/ic_nav_schedule.xml | 38 + app/src/main/res/drawable/ic_nav_settings.xml | 19 + app/src/main/res/drawable/ic_nav_tools.xml | 39 + .../main/res/drawable/ic_network_recharge.xml | 64 +- app/src/main/res/drawable/ic_peoples.xml | 35 + app/src/main/res/drawable/ic_permission.xml | 40 + app/src/main/res/drawable/ic_phonebook.xml | 55 +- app/src/main/res/drawable/ic_refresh.xml | 28 + app/src/main/res/drawable/ic_repository.xml | 39 +- .../res/drawable/ic_round_business_24.xml | 31 +- app/src/main/res/drawable/ic_schedule.xml | 56 +- app/src/main/res/drawable/ic_send.xml | 20 + .../main/res/drawable/ic_setting_config.xml | 70 ++ app/src/main/res/drawable/ic_topic.xml | 41 + app/src/main/res/drawable/ic_update.xml | 28 + app/src/main/res/drawable/ic_weather.xml | 44 +- app/src/main/res/drawable/lost_and_found.xml | 32 +- 80 files changed, 7864 insertions(+), 2091 deletions(-) create mode 100644 app/src/main/java/com/ahu/ahutong/data/model/UiStyle.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/components/GlassCard.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/components/LiquidGlass.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/components/LocalUiStyle.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/components/PayCapsuleConfirmButton.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/components/SecondaryPageHeader.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/components/SecondaryPageScaffold.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/screen/main/MoreWidgetsScreen.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeDateRow.kt create mode 100644 app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongDockState.kt create mode 100644 app/src/main/res/drawable/ic_add.xml create mode 100644 app/src/main/res/drawable/ic_aiming.xml create mode 100644 app/src/main/res/drawable/ic_announcement.xml create mode 100644 app/src/main/res/drawable/ic_check_one.xml create mode 100644 app/src/main/res/drawable/ic_clear.xml create mode 100644 app/src/main/res/drawable/ic_config.xml create mode 100644 app/src/main/res/drawable/ic_download.xml create mode 100644 app/src/main/res/drawable/ic_filter.xml create mode 100644 app/src/main/res/drawable/ic_find.xml create mode 100644 app/src/main/res/drawable/ic_income.xml create mode 100644 app/src/main/res/drawable/ic_log.xml create mode 100644 app/src/main/res/drawable/ic_logout.xml create mode 100644 app/src/main/res/drawable/ic_more_all.xml create mode 100644 app/src/main/res/drawable/ic_nav_degree_hat.xml create mode 100644 app/src/main/res/drawable/ic_nav_home.xml create mode 100644 app/src/main/res/drawable/ic_nav_plan.xml create mode 100644 app/src/main/res/drawable/ic_nav_schedule.xml create mode 100644 app/src/main/res/drawable/ic_nav_settings.xml create mode 100644 app/src/main/res/drawable/ic_nav_tools.xml create mode 100644 app/src/main/res/drawable/ic_peoples.xml create mode 100644 app/src/main/res/drawable/ic_permission.xml create mode 100644 app/src/main/res/drawable/ic_refresh.xml create mode 100644 app/src/main/res/drawable/ic_send.xml create mode 100644 app/src/main/res/drawable/ic_setting_config.xml create mode 100644 app/src/main/res/drawable/ic_topic.xml create mode 100644 app/src/main/res/drawable/ic_update.xml diff --git a/.gitignore b/.gitignore index 217ef672..fd10092b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ /local.properties .DS_Store /build +app/build/ +*.apk +*.aab +*.keystore /captures .externalNativeBuild .cxx 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..0739a572 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 @@ -416,6 +416,14 @@ object AHUCache { ?: defaultHomeWidgetSlots() } + /** 用户是否曾自定义主页插槽(true=已保存过布局,false=从未设置)。 */ + fun hasCustomHomeWidgetSlots(): Boolean { + val data = userGetStringOrMigrate(HOME_WIDGET_SLOTS_KEY) { + kv.decodeString(HOME_WIDGET_SLOTS_KEY) + } ?: "" + return data.isNotBlank() + } + fun saveHomeWidgetSlots(slots: List) { val normalizedSlots = normalizeHomeWidgetSlots(slots) val data = Gson().toJson(normalizedSlots) 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..080b54d5 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.UiStyle import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -17,6 +18,7 @@ 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_STYLE = stringPreferencesKey("ui_style") val COURSE_REMINDER_ENABLED = booleanPreferencesKey("course_reminder_enabled") val COURSE_REMINDER_LIVE_COUNTDOWN_ENABLED = booleanPreferencesKey("course_reminder_live_countdown_enabled") @@ -248,6 +250,18 @@ class PreferencesManager @Inject constructor(@param:ApplicationContext private v } } + val uiStyle: Flow = context.dataStore.data.map { prefs -> + prefs[PreferencesKeys.UI_STYLE]?.let(UiStyle::fromStorage) ?: UiStyle.RADIANT_UI + } + + suspend fun setUiStyle(value: UiStyle) { + context.dataStore.edit { prefs -> + prefs[PreferencesKeys.UI_STYLE] = value.storageValue + // 同步旧的液态玻璃开关,避免两套状态分叉(RADIANT_UI 与 LIQUID_GLASS 都用玻璃) + prefs[PreferencesKeys.USE_LIQUID_GLASS] = value != UiStyle.ORIGINAL + } + } + val courseReminderEnabled: Flow = context.dataStore.data.map { prefs -> prefs[PreferencesKeys.COURSE_REMINDER_ENABLED] ?: false } diff --git a/app/src/main/java/com/ahu/ahutong/data/model/UiStyle.kt b/app/src/main/java/com/ahu/ahutong/data/model/UiStyle.kt new file mode 100644 index 00000000..320c7e24 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/data/model/UiStyle.kt @@ -0,0 +1,23 @@ +package com.ahu.ahutong.data.model + +/** + * 整套 UI 的风格模式。 + * + * - ORIGINAL:经典原样式(无玻璃、原始组件与交互) + * - LIQUID_GLASS:液态玻璃风格(Apple 风格玻璃控件 + 浮动导航) + * - RADIANT_UI:曜光 RadiantUI(本项目新主页 + 玻璃 + 扁平导航的整合风格) + */ +enum class UiStyle(val storageValue: String) { + ORIGINAL("original"), + LIQUID_GLASS("liquid_glass"), + RADIANT_UI("radiant_ui"); + + /** 是否启用液态玻璃底层(玻璃是 RADIANT_UI 的基础材质之一)。 */ + val usesGlass: Boolean + get() = this != ORIGINAL + + companion object { + fun fromStorage(value: String?): UiStyle = + entries.firstOrNull { it.storageValue == value } ?: RADIANT_UI + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/ui/components/GlassCard.kt b/app/src/main/java/com/ahu/ahutong/ui/components/GlassCard.kt new file mode 100644 index 00000000..e3eead30 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/components/GlassCard.kt @@ -0,0 +1,68 @@ +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.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp +import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape + +/** + * 统一的玻璃化卡片容器(安全实现,不使用 liquidGlassSurface/backdrop)。 + * + * 只在 RadiantUI 下渲染高光玻璃观感(半透明玻璃底 + 1dp 高光描边 + 柔和阴影); + * 其余模式回退为 `clip + background(containerColor)` 的实色原观感,保证 OR/LG 冻结。 + * + * 注意:项目内 `liquidGlassSurface` 在 NavHost 子页面/destination 中使用会触发 + * libhwui 无限递归崩溃(见 ChangeableUI §7.3),故这里统一用伪玻璃模拟,零崩溃风险。 + * + * @param overlayColor 玻璃上额外叠的一层底色(如雨伞卡的蓝/绿语义色薄层);非玻璃分支忽略 + * @param glassShadow 玻璃底投影高度(Radiant 生效) + */ +@Composable +fun GlassCard( + modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.surfaceVariant, + overlayColor: Color? = null, + shape: Shape = SmoothRoundedCornerShape(24.dp), + glassShadow: androidx.compose.ui.unit.Dp? = 14.dp, + content: @Composable () -> Unit = {} +) { + val glass = isRadiantUi + // 玻璃底用完全不透明的主题表面色(随亮/暗取白/黑系),杜绝阴影从卡片内部透出 + val base = if (glass) { + MaterialTheme.colorScheme.surfaceContainerLowest + } else { + containerColor + } + Box( + modifier = modifier.then( + if (glass) { + Modifier + .then(if (glassShadow != null) Modifier.shadow(glassShadow, shape, clip = false) else Modifier) + .background(base, shape) + .border(1.dp, Color.White.copy(alpha = 0.28f), shape) + } else { + Modifier + .clip(shape) + .background(containerColor) + } + ) + ) { + if (glass && overlayColor != null) { + Box( + Modifier + .matchParentSize() + .clip(shape) + .background(overlayColor) + ) + } + content() + } +} \ No newline at end of file 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..e88aa2cc 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/components/LiquidBottomTabs.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/components/LiquidBottomTabs.kt @@ -3,6 +3,8 @@ package com.ahu.ahutong.ui.components import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.EaseOut import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row @@ -29,6 +31,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.semantics.clearAndSetSemantics @@ -68,6 +71,7 @@ fun LiquidBottomTabs( backdrop: Backdrop, tabsCount: Int, modifier: Modifier = Modifier, + onCurrentTabTapped: (() -> Unit)? = null, content: @Composable RowScope.() -> Unit ) { val isLiquid = LocalIsLiquidGlassEnabled.current @@ -267,6 +271,21 @@ fun LiquidBottomTabs( } .then(interactiveHighlight.gestureModifier) .then(dampedDragAnimation.modifier) + .then( + if (onCurrentTabTapped != null) { + Modifier.pointerInput(onCurrentTabTapped) { + awaitPointerEventScope { + while (true) { + val down = awaitFirstDown(requireUnconsumed = false) + val up = waitForUpOrCancellation() + if (up != null && !down.isConsumed) onCurrentTabTapped() + } + } + } + } else { + Modifier + } + ) .drawBackdrop( backdrop = rememberCombinedBackdrop(backdrop, tabsBackdrop), shape = { ContinuousCapsule }, diff --git a/app/src/main/java/com/ahu/ahutong/ui/components/LiquidGlass.kt b/app/src/main/java/com/ahu/ahutong/ui/components/LiquidGlass.kt new file mode 100644 index 00000000..340a0de6 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/components/LiquidGlass.kt @@ -0,0 +1,104 @@ +package com.ahu.ahutong.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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.unit.Dp +import androidx.compose.ui.unit.dp +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 + +/** + * 液态玻璃:将当前组件表面的背景替换为毛玻璃质感。 + * 依赖页面最外层由 [GlassBackdropContainer] 创建的 backdrop 背景层。 + */ +fun Modifier.liquidGlassSurface( + backdrop: Backdrop, + shape: Shape, + surfaceColor: Color, + shadowRadius: Dp = 14.dp +): Modifier = drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(18.dp.toPx()) + }, + shadow = { + Shadow( + radius = shadowRadius, + color = Color.Black.copy(alpha = 0.12f) + ) + }, + onDrawSurface = { + drawRect(surfaceColor) + } +) + +/** 液态玻璃卡片表面的着色,随亮/暗主题自动取色。 */ +@Composable +fun liquidGlassTint(): Color { + val isDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f + return if (isDark) { + MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.64f) + } else { + Color.White.copy(alpha = 0.46f) + } +} + +/** + * 液态玻璃容器:为页面提供 backdrop 背景采样层与材质背景。 + * 开启液态玻璃时为素色底 + 渐变色带(增强玻璃质感),关闭时为纯 surface。 + */ +@Composable +fun GlassBackdropContainer( + modifier: Modifier = Modifier, + content: @Composable BoxScope.(Backdrop) -> Unit +) { + val backdrop = rememberLayerBackdrop() + val liquid = LocalIsLiquidGlassEnabled.current + val background = if (liquid) { + MaterialTheme.colorScheme.surfaceContainerLowest + } else { + MaterialTheme.colorScheme.surface + } + val primary = MaterialTheme.colorScheme.primary + val secondary = MaterialTheme.colorScheme.secondary + + Box(modifier = modifier.background(background)) { + Box( + modifier = Modifier + .matchParentSize() + .clipToBounds() + .layerBackdrop(backdrop) + .background( + if (liquid) { + Brush.verticalGradient( + listOf( + background, + primary.copy(alpha = 0.08f), + secondary.copy(alpha = 0.05f), + background + ) + ) + } else { + Brush.linearGradient(listOf(background, background)) + } + ) + ) + content(backdrop) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/ui/components/LocalUiStyle.kt b/app/src/main/java/com/ahu/ahutong/ui/components/LocalUiStyle.kt new file mode 100644 index 00000000..55026dc2 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/components/LocalUiStyle.kt @@ -0,0 +1,15 @@ +package com.ahu.ahutong.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import com.ahu.ahutong.data.model.UiStyle + +/** + * 全局 UI 风格模式(Original / Liquid Glass / RadiantUI)。 + * 由 AHUTheme 从 PreferencesViewModel.uiStyle 提供。 + */ +val LocalUiStyle = compositionLocalOf { UiStyle.LIQUID_GLASS } + +/** 是否处于「曜光 RadiantUI」整合格局(新主页 + 学习通日历提级 + 扁平导航)。 */ +val isRadiantUi: Boolean + @Composable get() = LocalUiStyle.current == UiStyle.RADIANT_UI \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/ui/components/PayCapsuleConfirmButton.kt b/app/src/main/java/com/ahu/ahutong/ui/components/PayCapsuleConfirmButton.kt new file mode 100644 index 00000000..86f79b2c --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/components/PayCapsuleConfirmButton.kt @@ -0,0 +1,144 @@ +package com.ahu.ahutong.ui.components + +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 +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +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.res.painterResource +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 com.ahu.ahutong.R +import com.ahu.ahutong.data.crawler.PayState +import com.ahu.ahutong.ui.state.PaymentState +import com.kyant.monet.a1 +import com.kyant.monet.n1 +import com.kyant.monet.withNight + +/** + * 标题栏右上角的支付胶囊确认按钮: + * 确认(34dp 圆、黑色 check-one 勾) → 支付中(转圈) → 成功(自身撑开显示真实订单号)/失败(红叉)。 + * 与底部大确认按钮不同,这是 Radiant 标题栏的统一动作键。两种状态类型各一个重载,外观一致。 + */ +@Composable +fun ConfirmPayButton( + payState: State, + onClick: () -> Unit +) { + PayCapsuleCore( + idle = payState.value is PayState.Idle, + submitting = payState.value is PayState.InProgress, + orderId = (payState.value as? PayState.Succeeded)?.message, + failed = payState.value is PayState.Failed, + onClick = onClick + ) +} + +@Composable +fun ConfirmPayCapsule( + paymentState: State, + onClick: () -> Unit +) { + PayCapsuleCore( + idle = paymentState.value is PaymentState.Idle, + submitting = paymentState.value is PaymentState.Loading, + orderId = (paymentState.value as? PaymentState.Success)?.orderId, + failed = paymentState.value is PaymentState.Error, + onClick = onClick + ) +} + +@Composable +private fun PayCapsuleCore( + idle: Boolean, + submitting: Boolean, + orderId: String?, + failed: Boolean, + onClick: () -> Unit +) { + val bg by animateColorAsState( + targetValue = when { + idle -> 90.a1 withNight 85.a1 + submitting -> Color(0xFF558B2F) + orderId != null && !failed -> Color(0xFF2E7D32) + failed -> Color(0xFFD32F2F) + else -> Color(0xFF2E7D32) + }, + label = "confirmPayBg" + ) + Box( + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .background(bg) + .animateContentSize(spring(stiffness = Spring.StiffnessLow)) + .then(if (idle) Modifier.clickable(onClick = onClick) else Modifier), + contentAlignment = Alignment.Center + ) { + when { + idle -> Box(Modifier.size(34.dp), contentAlignment = Alignment.Center) { + Icon( + painterResource(R.drawable.ic_check_one), + "确认", + tint = Color.Black, + modifier = Modifier.size(20.dp) + ) + } + + submitting -> Box(Modifier.size(34.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), color = 100.n1, strokeWidth = 3.dp + ) + } + + failed -> Box(Modifier.size(34.dp), contentAlignment = Alignment.Center) { + Icon(Icons.Default.Close, "支付失败", tint = 100.n1, modifier = Modifier.size(18.dp)) + } + + else -> Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp) + ) { + Text( + "支付成功", + color = 100.n1, + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + lineHeight = 12.sp + ) + Spacer(Modifier.height(2.dp)) + Text( + text = orderId ?: "", + color = 100.n1.copy(alpha = 0.92f), + maxLines = 1, + fontSize = 10.sp, + lineHeight = 10.sp, + softWrap = false, + overflow = TextOverflow.Clip + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/ui/components/SecondaryPageHeader.kt b/app/src/main/java/com/ahu/ahutong/ui/components/SecondaryPageHeader.kt new file mode 100644 index 00000000..6d814c05 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/components/SecondaryPageHeader.kt @@ -0,0 +1,152 @@ +package com.ahu.ahutong.ui.components + +import androidx.compose.foundation.background +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.RowScope +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.statusBarsPadding +import androidx.compose.foundation.shape.CircleShape +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** 二级页标题栏右侧的操作按钮。 */ +data class TrailingAction( + val icon: ImageVector, + val contentDescription: String, + val onClick: () -> Unit +) + +/** + * 统一的二级页面标题栏。 + * RadiantUI 走新规范样式(固定高度标题栏,右侧最多 3 个统一按钮,可不填满); + * Original / Liquid Glass 走保守兼容分支,保持既有标题栏观感。 + */ +@Composable +fun SecondaryPageHeader( + title: String, + modifier: Modifier = Modifier, + actions: List = emptyList(), + maxActions: Int = 3, + subtitle: String? = null, + trailingContent: (@Composable RowScope.() -> Unit)? = null +) { + val limited = actions.take(maxActions) + if (isRadiantUi) { + val headerBg = if (LocalIsLiquidGlassEnabled.current) { + MaterialTheme.colorScheme.surfaceContainerLowest + } else { + MaterialTheme.colorScheme.surface + } + val rowHeight = if (subtitle != null) 64.dp else 60.dp + Column( + modifier = modifier + .fillMaxWidth() + .background( + Brush.verticalGradient( + colorStops = arrayOf( + 0f to headerBg, + 0.35f to headerBg, + 0.68f to headerBg.copy(alpha = 0.85f), + 1f to headerBg.copy(alpha = 0f) + ) + ) + ) + .statusBarsPadding() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(rowHeight), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(start = 22.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + modifier = Modifier.padding(top = 1.dp) + ) + } + } + if (limited.isNotEmpty() || trailingContent != null) { + Row( + modifier = Modifier.padding(end = 22.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + limited.forEach { action -> + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center + ) { + IconButton(onClick = action.onClick) { + Icon( + imageVector = action.icon, + contentDescription = action.contentDescription, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp) + ) + } + } + } + trailingContent?.invoke(this) + } + } + } + } + } else { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + if (limited.isNotEmpty()) { + Row { + limited.forEach { action -> + IconButton(onClick = action.onClick) { + Icon(action.icon, action.contentDescription) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/ui/components/SecondaryPageScaffold.kt b/app/src/main/java/com/ahu/ahutong/ui/components/SecondaryPageScaffold.kt new file mode 100644 index 00000000..9dd5f9c8 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/components/SecondaryPageScaffold.kt @@ -0,0 +1,272 @@ +package com.ahu.ahutong.ui.components + +import androidx.compose.foundation.background +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.padding +import androidx.compose.foundation.layout.statusBarsPadding +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.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.kyant.monet.a1 +import com.kyant.monet.n1 +import com.kyant.monet.withNight + +/** 二级页搜索态的交互状态。 */ +data class SecondarySearchState( + val query: String, + val visible: Boolean, + val placeholder: String = "输入城市名,如 合肥", + val onQueryChange: (String) -> Unit, + val onClose: () -> Unit, + val onSubmit: () -> Unit +) + +/** + * 统一的二级页面脚手架。 + * + * RadiantUI:背景 + 顶部固定悬浮标题栏(正常态标题栏 / 搜索态搜索栏),内容区在标题栏 + * 下滚动、可从其渐变遮罩下穿过。Original / Liquid Glass:完整还原原始观感——整页滚动、 + * 标题栏随内容滚动、搜索态内联在标题行。 + * + * 两种风格的结构差异全部收敛在本组件;后续页面只需传标题、按钮、搜索态与正文。以后删除 + * Original/Liquid Glass 时,删掉本文件的 else 分支即可,所有页面自动只剩 Radiant。 + */ +@Composable +fun SecondaryPageScaffold( + title: String, + modifier: Modifier = Modifier, + actions: List = emptyList(), + subtitle: String? = null, + search: SecondarySearchState? = null, + trailingContent: (@Composable androidx.compose.foundation.layout.RowScope.() -> Unit)? = null, + contentEdgeToEdge: Boolean = false, + content: @Composable () -> Unit = {} +) { + if (isRadiantUi) { + RadiantScaffold(title, modifier, actions, subtitle, search, trailingContent, contentEdgeToEdge, content) + } else { + ClassicScaffold(title, modifier, actions, subtitle, search, content) + } +} + +@Composable +private fun RadiantScaffold( + title: String, + modifier: Modifier, + actions: List, + subtitle: String?, + search: SecondarySearchState?, + trailingContent: (@Composable androidx.compose.foundation.layout.RowScope.() -> Unit)?, + contentEdgeToEdge: Boolean, + content: @Composable () -> Unit +) { + Box( + modifier = modifier + .fillMaxSize() + .background(96.n1 withNight 10.n1) + ) { + if (search?.visible == true) { + RadiantSearchHeader(search, Modifier.align(Alignment.TopCenter).zIndex(20f)) + } else { + SecondaryPageHeader( + title = title, + actions = actions, + subtitle = subtitle, + trailingContent = trailingContent, + modifier = Modifier.align(Alignment.TopCenter).zIndex(20f) + ) + } + if (contentEdgeToEdge) { + // 内容自绘滚动/边距/系统栏:不设外置顶部占位,正文可向上穿入半透标题栏之下; + // 滚动到顶时的停靠占位由各页面滚动容器通过 contentPadding / 顶部占位自行提供 + Column( + modifier = Modifier + .fillMaxSize() + ) { + content() + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(top = if (subtitle != null) 76.dp else 72.dp, start = 16.dp, end = 16.dp, bottom = 16.dp) + ) { + content() + } + } + } +} + +@Composable +private fun RadiantSearchHeader( + search: SecondarySearchState, + modifier: Modifier = Modifier +) { + val headerBg = if (LocalIsLiquidGlassEnabled.current) { + MaterialTheme.colorScheme.surfaceContainerLowest + } else { + MaterialTheme.colorScheme.surface + } + Column( + modifier = modifier + .fillMaxWidth() + .background( + Brush.verticalGradient( + colorStops = arrayOf( + 0f to headerBg, + 0.35f to headerBg, + 0.68f to headerBg.copy(alpha = 0.85f), + 1f to headerBg.copy(alpha = 0f) + ) + ) + ) + .statusBarsPadding() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(60.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = search.onClose, + modifier = Modifier.padding(start = 2.dp, end = 4.dp) + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + "关闭搜索", + tint = MaterialTheme.colorScheme.onSurface + ) + } + OutlinedTextField( + value = search.query, + onValueChange = search.onQueryChange, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + singleLine = true, + placeholder = { Text(search.placeholder) }, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = 0.n1 withNight 100.n1, + unfocusedTextColor = 0.n1 withNight 100.n1, + cursorColor = 90.a1 withNight 90.a1 + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { search.onSubmit() }), + trailingIcon = { + if (search.query.isNotEmpty()) { + IconButton(onClick = { search.onQueryChange("") }) { + Icon(Icons.Default.Close, "清空", tint = MaterialTheme.colorScheme.onSurface) + } + } else { + IconButton(onClick = search.onSubmit) { + Icon(Icons.Default.Search, "搜索", tint = MaterialTheme.colorScheme.onSurface) + } + } + } + ) + } + } +} + +@Composable +private fun ClassicScaffold( + title: String, + modifier: Modifier, + actions: List, + subtitle: String?, + search: SecondarySearchState?, + content: @Composable () -> Unit +) { + Column( + 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 (search?.visible == true) { + IconButton(onClick = search.onClose) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "关闭搜索") + } + OutlinedTextField( + value = search.query, + onValueChange = search.onQueryChange, + modifier = Modifier.weight(1f), + singleLine = true, + placeholder = { Text(search.placeholder) }, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = 0.n1 withNight 100.n1, + unfocusedTextColor = 0.n1 withNight 100.n1, + cursorColor = 90.a1 withNight 90.a1 + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { search.onSubmit() }), + trailingIcon = { + if (search.query.isNotEmpty()) { + IconButton(onClick = { search.onQueryChange("") }) { + Icon(Icons.Default.Close, "清空") + } + } else { + IconButton(onClick = search.onSubmit) { + Icon(Icons.Default.Search, "搜索") + } + } + } + ) + } else { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + Row { + actions.forEach { action -> + IconButton(onClick = action.onClick) { + Icon(action.icon, action.contentDescription) + } + } + } + } + } + + content() + } +} \ No newline at end of file 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..e478f036 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 @@ -47,10 +47,8 @@ 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.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight @@ -58,12 +56,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.kyant.backdrop.Backdrop -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import com.kyant.backdrop.drawBackdrop -import com.kyant.backdrop.effects.blur -import com.kyant.backdrop.effects.vibrancy -import com.kyant.backdrop.shadow.Shadow data class SettingsChoice( val value: T, @@ -145,13 +137,6 @@ fun SettingsConfirmationDialog( } } -@Composable -fun settingsScreenBackground(): Color = if (LocalIsLiquidGlassEnabled.current) { - MaterialTheme.colorScheme.surfaceContainerLowest -} else { - MaterialTheme.colorScheme.surface -} - @Composable fun settingsGroupColor(): Color = if (LocalIsLiquidGlassEnabled.current) { MaterialTheme.colorScheme.surface.copy(alpha = 0.86f) @@ -164,35 +149,7 @@ fun SettingsBackdropContainer( modifier: Modifier = Modifier, content: @Composable BoxScope.(Backdrop) -> Unit ) { - val backdrop = rememberLayerBackdrop() - val liquid = LocalIsLiquidGlassEnabled.current - val background = settingsScreenBackground() - val primary = MaterialTheme.colorScheme.primary - val secondary = MaterialTheme.colorScheme.secondary - - Box(modifier = modifier.background(background)) { - Box( - modifier = Modifier - .matchParentSize() - .clipToBounds() - .layerBackdrop(backdrop) - .background( - if (liquid) { - Brush.verticalGradient( - listOf( - background, - primary.copy(alpha = 0.08f), - secondary.copy(alpha = 0.05f), - background - ) - ) - } else { - Brush.linearGradient(listOf(background, background)) - } - ) - ) - content(backdrop) - } + GlassBackdropContainer(modifier = modifier, content = content) } @Composable @@ -204,12 +161,7 @@ fun SettingsPageHeader( ) { 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 glassTint = liquidGlassTint() Row( modifier = modifier .fillMaxWidth() @@ -266,12 +218,7 @@ fun SettingsHeroCard( ) { 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 glassTint = liquidGlassTint() Row( modifier = modifier .fillMaxWidth() @@ -301,12 +248,7 @@ fun SettingsSection( ) { 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) - } + val glassTint = liquidGlassTint() Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp) @@ -339,28 +281,6 @@ fun SettingsSection( } } -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, @@ -368,6 +288,7 @@ fun SettingsActionRow( modifier: Modifier = Modifier, subtitle: String? = null, leadingIcon: ImageVector? = null, + leadingPainter: Painter? = null, value: String? = null, destructive: Boolean = false, showChevron: Boolean = true, @@ -383,7 +304,22 @@ fun SettingsActionRow( horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically ) { - leadingIcon?.let { + if (leadingPainter != null) { + Box( + modifier = Modifier + .size(40.dp) + .clip(SmoothRoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + painter = leadingPainter, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } else leadingIcon?.let { Box( modifier = Modifier .size(40.dp) @@ -420,7 +356,7 @@ fun SettingsActionRow( ) } } - SettingsDivider(visible = showDivider, leadingInset = if (leadingIcon == null) 20.dp else 74.dp) + SettingsDivider(visible = showDivider, leadingInset = if (leadingIcon == null && leadingPainter == null) 20.dp else 74.dp) } } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/BottomNavBar.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/BottomNavBar.kt index 718fc8cc..cfdccb3e 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 @@ -20,38 +20,178 @@ 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.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState +import com.ahu.ahutong.R 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.isRadiantUi +import com.ahu.ahutong.ui.screen.xuexiaotong.XuexiaotongDockState +import com.ahu.ahutong.ui.screen.xuexiaotong.XuexiaotongSubTab import com.kyant.backdrop.Backdrop -private data class BottomDestination( +@Composable +fun BoxScope.BottomNavBar( + navController: NavHostController, + backdrop: Backdrop +) { + if (isRadiantUi) { + RadiantBottomNavBar(navController, backdrop) + } else { + ClassicBottomNavBar(navController, backdrop) + } +} + +private fun NavController.navigatePreservingHome(route: String) { + if (currentBackStackEntry?.destination?.route == route) return + navigate(route) { + popUpTo("home") { inclusive = false } + launchSingleTop = true + } +} + +// ==================== 曜光版:学习通日历提级为第三 tab(日程/课程轮换) ==================== + +private data class RadiantDestination( val route: String, val label: String, - val selectedIcon: ImageVector, - val unselectedIcon: ImageVector + val icon: Painter ) -private val bottomDestinations = listOf( - BottomDestination("home", "主页", Icons.Outlined.Home, Icons.Outlined.Home), - BottomDestination("schedule", "课表", Icons.Outlined.TableChart, Icons.Outlined.TableChart), - BottomDestination("tools", "小工具", Icons.Outlined.Build, Icons.Outlined.Build), - BottomDestination("settings", "设置", Icons.Outlined.Settings, Icons.Outlined.Settings) +@Composable +private fun BoxScope.RadiantBottomNavBar( + navController: NavHostController, + backdrop: Backdrop +) { + val onXuexiaotongSub = XuexiaotongDockState.tab == XuexiaotongSubTab.SCHEDULE + val destinations = listOf( + RadiantDestination("home", "主页", painterResource(R.drawable.ic_nav_home)), + RadiantDestination("schedule", "课表", painterResource(R.drawable.ic_nav_schedule)), + RadiantDestination( + "xuexiaotong", + if (onXuexiaotongSub) "日程" else "课程", + painterResource(if (onXuexiaotongSub) R.drawable.ic_nav_plan else R.drawable.ic_nav_degree_hat) + ), + RadiantDestination("settings", "设置", painterResource(R.drawable.ic_nav_settings)) + ) + + val currentRoute by navController.currentBackStackEntryAsState() + val selectedRoute = currentRoute?.destination?.route + if (selectedRoute !in destinations.map { it.route }) return + + fun onTabTapped(route: String) { + if (route == navController.currentBackStackEntry?.destination?.route) { + if (route == "xuexiaotong") XuexiaotongDockState.toggle() + return + } + navController.navigatePreservingHome(route) + } + + fun navigateToTab(route: String) { + navController.navigatePreservingHome(route) + } + + if (LocalIsLiquidGlassEnabled.current) { + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(vertical = 16.dp) + .navigationBarsPadding() + ) { + LiquidBottomTabs( + selectedTabIndex = { + destinations.indexOfFirst { it.route == selectedRoute }.coerceAtLeast(0) + }, + onTabSelected = { index -> + navigateToTab(destinations[index].route) + }, + onCurrentTabTapped = { + selectedRoute?.let { onTabTapped(it) } + }, + backdrop = backdrop, + tabsCount = destinations.size, + modifier = Modifier.padding(horizontal = 36.dp) + ) { + destinations.forEach { destination -> + val selected = selectedRoute == destination.route + LiquidBottomTab( + onClick = { onTabTapped(destination.route) } + ) { + Icon( + painter = destination.icon, + contentDescription = destination.label + ) + Text( + text = destination.label, + style = MaterialTheme.typography.labelMedium + ) + } + } + } + } + } else { + NavigationBar( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + containerColor = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 0.dp + ) { + destinations.forEach { destination -> + val selected = selectedRoute == destination.route + NavigationBarItem( + selected = selected, + onClick = { onTabTapped(destination.route) }, + icon = { + Icon( + painter = destination.icon, + contentDescription = destination.label + ) + }, + label = { Text(destination.label) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + selectedTextColor = MaterialTheme.colorScheme.onSurface, + indicatorColor = MaterialTheme.colorScheme.secondaryContainer, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + } + } +} + +// ==================== 经典版:小工具第三 tab(原样式) ==================== + +private data class ClassicDestination( + val route: String, + val label: String, + val icon: ImageVector +) + +private val classicDestinations = listOf( + ClassicDestination("home", "主页", Icons.Outlined.Home), + ClassicDestination("schedule", "课表", Icons.Outlined.TableChart), + ClassicDestination("tools", "小工具", Icons.Outlined.Build), + ClassicDestination("settings", "设置", Icons.Outlined.Settings) ) @Composable -fun BoxScope.BottomNavBar( +private fun BoxScope.ClassicBottomNavBar( navController: NavHostController, backdrop: Backdrop ) { val currentRoute by navController.currentBackStackEntryAsState() val selectedRoute = currentRoute?.destination?.route - if (selectedRoute !in bottomDestinations.map { it.route }) return + if (selectedRoute !in classicDestinations.map { it.route }) return if (LocalIsLiquidGlassEnabled.current) { Row( @@ -63,16 +203,16 @@ fun BoxScope.BottomNavBar( ) { LiquidBottomTabs( selectedTabIndex = { - bottomDestinations.indexOfFirst { it.route == selectedRoute }.coerceAtLeast(0) + classicDestinations.indexOfFirst { it.route == selectedRoute }.coerceAtLeast(0) }, onTabSelected = { index -> - navController.navigatePreservingHome(bottomDestinations[index].route) + navController.navigatePreservingHome(classicDestinations[index].route) }, backdrop = backdrop, - tabsCount = bottomDestinations.size, + tabsCount = classicDestinations.size, modifier = Modifier.padding(horizontal = 36.dp) ) { - bottomDestinations.forEach { destination -> + classicDestinations.forEach { destination -> val selected = selectedRoute == destination.route LiquidBottomTab( onClick = { @@ -80,11 +220,7 @@ fun BoxScope.BottomNavBar( } ) { Icon( - imageVector = if (selected) { - destination.selectedIcon - } else { - destination.unselectedIcon - }, + imageVector = destination.icon, contentDescription = destination.label ) Text( @@ -103,18 +239,14 @@ fun BoxScope.BottomNavBar( containerColor = MaterialTheme.colorScheme.surfaceContainer, tonalElevation = 0.dp ) { - bottomDestinations.forEach { destination -> + classicDestinations.forEach { destination -> val selected = selectedRoute == destination.route NavigationBarItem( selected = selected, onClick = { navController.navigatePreservingHome(destination.route) }, icon = { Icon( - imageVector = if (selected) { - destination.selectedIcon - } else { - destination.unselectedIcon - }, + imageVector = destination.icon, contentDescription = destination.label ) }, @@ -130,12 +262,4 @@ fun BoxScope.BottomNavBar( } } } -} - -private fun NavController.navigatePreservingHome(route: String) { - if (currentBackStackEntry?.destination?.route == route) return - navigate(route) { - popUpTo("home") { inclusive = false } - launchSingleTop = true - } -} +} \ 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 8e0d8f9c..ca07baec 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 @@ -41,6 +41,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import com.ahu.ahutong.appwidget.ScheduleAppWidgetReceiver import com.ahu.ahutong.data.gray.GrayFeatures import com.ahu.ahutong.data.gray.GrayReleaseManager +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.screen.main.BathroomDeposit import com.ahu.ahutong.ui.screen.main.CardBalanceDeposit import com.ahu.ahutong.ui.screen.main.CmbCardRecharge @@ -51,6 +52,7 @@ import com.ahu.ahutong.ui.screen.main.FreeClassroom import com.ahu.ahutong.ui.screen.main.Grade import com.ahu.ahutong.ui.screen.main.Home import com.ahu.ahutong.ui.screen.main.LostFound +import com.ahu.ahutong.ui.screen.main.MoreWidgetsScreen import com.ahu.ahutong.ui.screen.main.NetworkRecharge import com.ahu.ahutong.ui.screen.main.PhoneBook import com.ahu.ahutong.ui.screen.main.Repository @@ -224,6 +226,16 @@ fun Main( } ) } + animatedComposable("widgets") { + MoreWidgetsScreen( + navController = navController, + homeEditEnabled = homeEditGrayState.enabled, + onEditHome = { + behaviorRuntime.recordActionIntentAsync(AppActionId.EDIT_HOME, ActionSource.ORGANIC) + shouldEnterHomeEdit = true + } + ) + } animatedComposable("school_calendar") { SchoolCalendar(navController = navController) } @@ -334,10 +346,7 @@ fun Main( animatedComposable("xuexiaotong") { val context = LocalContext.current val api = remember { com.ahu.ahutong.data.xuexiaotong.ChaoxingApi(context) } - XuexiaotongScreen( - api = api, - onBack = { navController.popBackStack() } - ) + XuexiaotongScreen(api = api) } animatedComposable("debug") { @@ -370,10 +379,11 @@ fun Main( backdrop = backdrop, blocked = productUiBlocked, hiddenForDiagnostics = diagnosticsRouteVisible, - bottomSpacing = if (currentRoute in setOf("home", "schedule", "tools", "settings")) { - 88.dp - } else { - 16.dp + bottomSpacing = when { + currentRoute in setOf("home", "schedule", "settings") -> 88.dp + isRadiantUi && currentRoute == "xuexiaotong" -> 88.dp + !isRadiantUi && currentRoute == "tools" -> 88.dp + else -> 16.dp }, onSuggestionClick = { suggestion -> scope.launch { 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..2fe44d3a 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 @@ -64,6 +64,7 @@ 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.isRadiantUi import com.ahu.ahutong.ui.components.SettingsPageHeader import com.ahu.ahutong.ui.components.SettingsSection import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape @@ -124,6 +125,7 @@ fun Settings( SettingsPageHeader(title = stringResource(id = R.string.setting)) val isLiquid = LocalIsLiquidGlassEnabled.current + val isRadiant = isRadiantUi val heroContentColor = if (isLiquid) { MaterialTheme.colorScheme.onSurface } else { @@ -174,7 +176,8 @@ fun Settings( ) SettingsActionRow( title = "重新登录", - leadingIcon = Icons.AutoMirrored.Outlined.Login, + leadingIcon = if (isRadiant) null else Icons.AutoMirrored.Outlined.Login, + leadingPainter = if (isRadiant) painterResource(R.drawable.ic_logout) else null, showDivider = false, onClick = { navController.navigate("login") } ) @@ -189,12 +192,14 @@ fun Settings( SettingsActionRow( title = stringResource(id = R.string.preferences), subtitle = "通知、外观、主页与智能体验", - leadingIcon = Icons.Outlined.Tune, + leadingIcon = if (isRadiant) null else Icons.Outlined.Tune, + leadingPainter = if (isRadiant) painterResource(R.drawable.ic_setting_config) else null, onClick = { navController.navigate("preferences") } ) SettingsActionRow( title = stringResource(id = R.string.check_update), - leadingIcon = Icons.Outlined.Update, + leadingIcon = if (isRadiant) null else Icons.Outlined.Update, + leadingPainter = if (isRadiant) painterResource(R.drawable.ic_update) else null, showDivider = false, onClick = { mainViewModel.checkApkUpdateManually(context) { message -> @@ -211,17 +216,20 @@ fun Settings( ) { SettingsActionRow( title = stringResource(id = R.string.license), - leadingIcon = Icons.AutoMirrored.Outlined.Article, + leadingIcon = if (isRadiant) null else Icons.AutoMirrored.Outlined.Article, + leadingPainter = if (isRadiant) painterResource(R.drawable.ic_announcement) else null, onClick = { navController.navigate("settings__license") } ) SettingsActionRow( title = stringResource(id = R.string.contributors), - leadingIcon = Icons.Outlined.PeopleOutline, + leadingIcon = if (isRadiant) null else Icons.Outlined.PeopleOutline, + leadingPainter = if (isRadiant) painterResource(R.drawable.ic_peoples) else null, onClick = { navController.navigate("settings__contributors") } ) SettingsActionRow( title = stringResource(id = R.string.mine_tv_feedback), - leadingIcon = Icons.Outlined.Feedback, + leadingIcon = if (isRadiant) null else Icons.Outlined.Feedback, + leadingPainter = if (isRadiant) painterResource(R.drawable.ic_topic) else null, onClick = { runCatching { context.startActivity( @@ -237,13 +245,15 @@ fun Settings( ) SettingsActionRow( title = stringResource(id = R.string.update_intro), - leadingIcon = Icons.AutoMirrored.Outlined.Article, + leadingIcon = if (isRadiant) null else Icons.AutoMirrored.Outlined.Article, + leadingPainter = if (isRadiant) painterResource(R.drawable.ic_log) else null, onClick = { isUpdateLogDialogShown = true } ) SettingsActionRow( title = stringResource(id = R.string.setting_clear), subtitle = "清除登录状态、课表和本地数据", - leadingIcon = Icons.Outlined.ClearAll, + leadingIcon = if (isRadiant) null else Icons.Outlined.ClearAll, + leadingPainter = if (isRadiant) painterResource(R.drawable.ic_clear) else null, destructive = true, showDivider = false, onClick = { isClearDataDialogShown = true } 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..37fb1faa 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 @@ -73,23 +73,26 @@ 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.components.GlassCard +import com.ahu.ahutong.ui.components.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.isRadiantUi 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 -import com.ahu.ahutong.personalization.action.AppActionId +import com.kyant.monet.withNight +import com.ahu.ahutong.personalization.ui.rememberBehaviorActionReporter +import com.ahu.ahutong.personalization.action.AppActionId import kotlinx.coroutines.delay @OptIn(ExperimentalMaterial3Api::class) @Composable -fun BathroomDeposit( +fun BathroomDeposit( viewmodel: BathroomDepositViewModel = viewModel() -) { - val behaviorReporter = rememberBehaviorActionReporter() +) { + val behaviorReporter = rememberBehaviorActionReporter() val payState = viewmodel.payState.collectAsState() LaunchedEffect(payState.value) { when (payState.value) { @@ -129,7 +132,382 @@ fun BathroomDeposit( unfocusedIndicatorColor = Color.Transparent, ) - Column( + var radiantShowDialog by remember { mutableStateOf(false) } + var radiantPassword by remember { mutableStateOf("") } + var radiantErrorMsg by remember { mutableStateOf(null) } + + if (isRadiantUi) { + SecondaryPageScaffold( + title = "浴室缴费", + content = { + Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { + GlassCard( + containerColor = 100.n1 withNight 20.n1, + modifier = Modifier.fillMaxWidth() + ) { + Column { + 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) + } + } + } + GlassCard( + containerColor = 100.n1 withNight 20.n1, + modifier = Modifier.fillMaxWidth() + ) { + Column { + 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 + ) + } + } + + 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) { + radiantShowDialog = 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 (radiantShowDialog) { + AlertDialog( + containerColor = 100.n1 withNight 20.n1, + titleContentColor = 10.n1 withNight 90.n1, + textContentColor = 10.n1 withNight 90.n1, + onDismissRequest = { radiantShowDialog = false }, + title = { Text("请输入校园卡密码") }, + text = { + Column { + OutlinedTextField( + value = radiantPassword, + onValueChange = { input -> + if (input.length <= 6 && input.all { it.isDigit() }) { + radiantPassword = input + radiantErrorMsg = null + } + }, + label = { Text("密码 (6位数字)", color = 40.n1 withNight 60.n1) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + visualTransformation = PasswordVisualTransformation(), + isError = radiantErrorMsg != null, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = 10.n1 withNight 90.n1, + unfocusedTextColor = 10.n1 withNight 90.n1, + focusedBorderColor = 20.n1 withNight 80.n1 + ) + ) + if (radiantErrorMsg != null) { + Text( + text = radiantErrorMsg!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } + }, + confirmButton = { + TextButton(onClick = { + if (radiantPassword.length == 6) { + radiantShowDialog = false + behaviorReporter.organic(AppActionId.CONFIRM_BATHROOM_PAYMENT) + viewmodel.pay( + bathroom = bathroom, + amount = amount, + password = radiantPassword + ) + } else { + radiantErrorMsg = "密码必须是6位数字" + } + }) { + Text("确认", color = 10.n1 withNight 90.n1) + } + }, + dismissButton = { + TextButton(onClick = { + radiantShowDialog = false + radiantPassword = "" + radiantErrorMsg = null + }) { + Text("取消", color = 10.n1 withNight 90.n1) + } + } + ) + } + } else { + Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) @@ -471,11 +849,11 @@ fun BathroomDeposit( if (showDialog) { - AlertDialog( - containerColor = 100.n1 withNight 20.n1, - titleContentColor = 10.n1 withNight 90.n1, - textContentColor = 10.n1 withNight 90.n1, - onDismissRequest = { showDialog = false }, + 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 { @@ -497,21 +875,21 @@ fun BathroomDeposit( focusedBorderColor = 20.n1 withNight 80.n1 ) ) - if (errorMsg != null) { - Text( - text = errorMsg!!, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall - ) - } - } + 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( + if (password.length == 6) { + showDialog = false + behaviorReporter.organic(AppActionId.CONFIRM_BATHROOM_PAYMENT) + viewmodel.pay( bathroom = bathroom, amount = amount, password = password @@ -539,4 +917,5 @@ fun BathroomDeposit( } } } +} 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..f1384791 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 @@ -6,14 +6,14 @@ import android.content.Context 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.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 @@ -29,36 +29,36 @@ 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 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.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 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.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.unit.dp import androidx.compose.ui.unit.sp @@ -66,12 +66,15 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import com.ahu.ahutong.data.dao.AHUCache import com.ahu.ahutong.data.mock.MockScenarioController +import com.ahu.ahutong.ui.components.GlassCard +import com.ahu.ahutong.ui.components.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape 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.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 @@ -90,7 +93,7 @@ fun CardBalanceDeposit( val behaviorReporter = rememberBehaviorActionReporter() var amount by remember { mutableStateOf("") } - + val cardInfo = viewModel.cardInfo.collectAsState() val accountState by viewModel.accountState.collectAsState() @@ -115,39 +118,409 @@ fun CardBalanceDeposit( viewModel.load() } } - - 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) + + if (isRadiantUi) { + SecondaryPageScaffold( + title = "校园卡充值", + content = { + Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { + GlassCard( + containerColor = 100.n1 withNight 20.n1, + modifier = Modifier.fillMaxWidth() + ) { + Column { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + ) { + Text( + text = "校园卡账户", + style = MaterialTheme.typography.titleMedium + ) + when (val state = accountState) { + CardAccountState.Loading -> { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = 30.n1 withNight 70.n1 + ) + } + + is CardAccountState.Ready -> { + val accountInfo = state.cardInfo.data.card.getOrNull(0) + ?.accinfo?.getOrNull(0) + Text( + text = accountInfo?.let { "${it.name} ${it.type}" } + ?: "--" + ) + } + + is CardAccountState.Error -> { + Text( + text = "加载失败", + color = Color.Red + ) + } + } + } + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = "账户余额", style = MaterialTheme.typography.titleMedium) + Text( + text = cardInfo.value?.data?.card?.getOrNull(0) + ?.accinfo?.getOrNull(0)?.balance + ?.let { String.format("¥%.2f", it / 100.0) } + ?: "¥--", + style = MaterialTheme.typography.titleMedium + ) + } + } + } + + GlassCard( + containerColor = 100.n1 withNight 20.n1, + modifier = Modifier.fillMaxWidth() + ) { + Column { + 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 + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(start = 24.dp, top = 16.dp, end = 16.dp, bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + 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 + ) + } + }, + confirmButton = { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + text = "支付宝支付", + modifier = Modifier + .clickable { + behaviorReporter.organic(AppActionId.SUBMIT_CARD_RECHARGE) + val identityState = copyCampusCardIdentity( + context = context, + name = campusCardUserName, + studentId = campusCardStudentId + ) + val message = when (identityState) { + CampusCardIdentityCopyState.Complete -> "已复制姓名和学号" + CampusCardIdentityCopyState.Partial -> "本地信息不完整,已复制可用信息" + 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") + } + .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 + ) + } + } + ) + } + } else { + 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) ) { Row( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - ) { - Text( - text = "校园卡账户", - style = MaterialTheme.typography.titleMedium - ) - + .padding(16.dp) + .fillMaxWidth(), + ) { + Text( + text = "校园卡账户", + style = MaterialTheme.typography.titleMedium + ) + when (val state = accountState) { CardAccountState.Loading -> { CircularProgressIndicator( @@ -173,72 +546,72 @@ fun CardBalanceDeposit( } } } - Row( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(text = "账户余额", style = MaterialTheme.typography.titleMedium) - Text( - text = cardInfo.value?.data?.card?.getOrNull(0) - ?.accinfo?.getOrNull(0)?.balance?.let { String.format("¥%.2f", it / 100.0) } - ?: "¥--", - style = MaterialTheme.typography.titleMedium - ) - } - - } - - 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 - ) - } - + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = "账户余额", style = MaterialTheme.typography.titleMedium) + Text( + text = cardInfo.value?.data?.card?.getOrNull(0) + ?.accinfo?.getOrNull(0)?.balance?.let { String.format("¥%.2f", it / 100.0) } + ?: "¥--", + style = MaterialTheme.typography.titleMedium + ) + } + + } + + 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 + ) + } + Row( modifier = Modifier @@ -261,126 +634,126 @@ fun CardBalanceDeposit( .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 }, + 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( @@ -450,11 +823,11 @@ fun CardBalanceDeposit( dismissButton = { Text( text = "取消", - modifier = Modifier - .clickable { showConfirmDialog = false } - .padding(8.dp), - color = 10.n1 withNight 90.n1 - ) + modifier = Modifier + .clickable { showConfirmDialog = false } + .padding(8.dp), + color = 10.n1 withNight 90.n1 + ) } ) } @@ -511,6 +884,7 @@ fun CardBalanceDeposit( } } +} private enum class CampusCardIdentityCopyState { Complete, 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..e6e4a452 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 @@ -13,14 +13,17 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.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.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -43,6 +46,7 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -58,12 +62,19 @@ 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.TextOverflow 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.data.model.ElectricityDepositHistoryItem +import com.ahu.ahutong.personalization.preset.PresetCandidate +import com.ahu.ahutong.ui.components.GlassCard +import com.ahu.ahutong.ui.components.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.ahu.ahutong.ui.state.CampusDataItem import com.ahu.ahutong.ui.state.ElectricityDepositViewModel import com.kyant.monet.a1 import com.kyant.monet.n1 @@ -88,7 +99,13 @@ fun ElectricityDeposit( val payState = viewModel.payState.collectAsState() LaunchedEffect(payState.value) { when (payState.value) { - is PayState.Succeeded, is PayState.Failed -> { + is PayState.Succeeded -> { + // 成功态展示订单号的时间拉长一些,方便看清 + delay(2400) + viewModel.resetPaymentState() + } + + is PayState.Failed -> { delay(1000) viewModel.resetPaymentState() } @@ -153,82 +170,611 @@ fun ElectricityDeposit( var password by remember { mutableStateOf("") } var errorMsg by remember { mutableStateOf(null) } - 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 + val onConfirmClick: () -> Unit = { + 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 + } + } + + if (isRadiantUi) { + SecondaryPageScaffold( + title = "电控缴费", + content = { + ElectricityFormBody( + campusList = campusList, selectedCampus = selectedCampus, + campusDropdownExpanded = campusDropdownExpanded, + onCampusDropdownChange = { campusDropdownExpanded = it }, + onCampusSelect = { campus -> + viewModel.onCampusSelected(campus) + campusDropdownExpanded = false + }, + buildingsList = buildingsList, selectedBuilding = selectedBuilding, + buildingsDropdownExpanded = buildingsDropdownExpanded, + onBuildingDropdownChange = { if (it) openBuildingMenu() else buildingsDropdownExpanded = false }, + onBuildingSelect = { b -> + viewModel.onBuildingSelected(b) + buildingsDropdownExpanded = false + }, + floorsList = floorsList, selectedFloor = selectedFloor, + floorsDropdownExpanded = floorsDropdownExpanded, + onFloorDropdownChange = { if (it) openFloorMenu() else floorsDropdownExpanded = false }, + onFloorSelect = { f -> + viewModel.onfloorSelected(f) + floorsDropdownExpanded = false + }, + roomsList = roomsList, selectedRoom = selectedRoom, + roomsDropdownExpanded = roomsDropdownExpanded, + onRoomDropdownChange = { if (it) openRoomMenu() else roomsDropdownExpanded = false }, + onRoomSelect = { r -> + viewModel.onRoomSelected(r) + roomsDropdownExpanded = false + }, + historyOptions = historyOptions, + roomInfo = roomInfo, + presetCandidates = presetCandidates, + onPresetVisible = { viewModel.onPresetCandidateVisible(it) }, + onPresetApply = { viewModel.applyPresetCandidate(it) }, + onHistorySelect = { viewModel.selectHistory(it) }, + onInfoClick = { + 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 + } + }, + onInfoLongClick = { showResetDialog = true }, + amount = amount, + onAmountChange = { newText -> + if (newText.isEmpty()) { + amount = newText + return@ElectricityFormBody + } + val regex = Regex("^\\d*\\.?\\d{0,2}$") + if (regex.matches(newText)) { + amount = newText + } + }, + onClearFocus = { focusManager.clearFocus() } + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp), + 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 (payState.value) { + is PayState.Idle -> { + Text( + text = "确认", + modifier = Modifier + .clickable( + role = Role.Button, + onClick = onConfirmClick + ) + .padding(24.dp, 16.dp), + color = 0.n1, + style = MaterialTheme.typography.titleMedium + ) + } + + 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 + ) + } + } + + is PayState.Succeeded -> { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = 100.n1 + ) + Text( + text = "支付成功! 订单号:${(payState.value as PayState.Succeeded).message}", + modifier = Modifier + .padding(4.dp) + .clickable { + + }, + color = 100.n1, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineSmall + ) + } + } + + 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 + ) + } + } + } + } + } + } ) + } else { + 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 + ) + + ElectricityFormBody( + campusList = campusList, selectedCampus = selectedCampus, + campusDropdownExpanded = campusDropdownExpanded, + onCampusDropdownChange = { campusDropdownExpanded = it }, + onCampusSelect = { campus -> + viewModel.onCampusSelected(campus) + campusDropdownExpanded = false + }, + buildingsList = buildingsList, selectedBuilding = selectedBuilding, + buildingsDropdownExpanded = buildingsDropdownExpanded, + onBuildingDropdownChange = { if (it) openBuildingMenu() else buildingsDropdownExpanded = false }, + onBuildingSelect = { b -> + viewModel.onBuildingSelected(b) + buildingsDropdownExpanded = false + }, + floorsList = floorsList, selectedFloor = selectedFloor, + floorsDropdownExpanded = floorsDropdownExpanded, + onFloorDropdownChange = { if (it) openFloorMenu() else floorsDropdownExpanded = false }, + onFloorSelect = { f -> + viewModel.onfloorSelected(f) + floorsDropdownExpanded = false + }, + roomsList = roomsList, selectedRoom = selectedRoom, + roomsDropdownExpanded = roomsDropdownExpanded, + onRoomDropdownChange = { if (it) openRoomMenu() else roomsDropdownExpanded = false }, + onRoomSelect = { r -> + viewModel.onRoomSelected(r) + roomsDropdownExpanded = false + }, + historyOptions = historyOptions, + roomInfo = roomInfo, + presetCandidates = presetCandidates, + onPresetVisible = { viewModel.onPresetCandidateVisible(it) }, + onPresetApply = { viewModel.applyPresetCandidate(it) }, + onHistorySelect = { viewModel.selectHistory(it) }, + onInfoClick = { + 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 + } + }, + onInfoLongClick = { showResetDialog = true }, + amount = amount, + onAmountChange = { newText -> + if (newText.isEmpty()) { + amount = newText + return@ElectricityFormBody + } + val regex = Regex("^\\d*\\.?\\d{0,2}$") + if (regex.matches(newText)) { + amount = newText + } + }, + onClearFocus = { focusManager.clearFocus() } + ) + + 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 (payState.value) { + is PayState.Idle -> { + Text( + text = "确认", + modifier = Modifier + .clickable( + role = Role.Button, + onClick = onConfirmClick + ) + .padding(24.dp, 16.dp), + color = 0.n1, + style = MaterialTheme.typography.titleMedium + ) + } + 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 + ) + } + } + + is PayState.Succeeded -> { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = 100.n1 + ) + Text( + text = "支付成功! 订单号:${(payState.value as PayState.Succeeded).message}", + modifier = Modifier + .padding(4.dp) + .clickable { + + }, + color = 100.n1, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineSmall + ) + } + } + + 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 + ) + } + } + } + } + } + } + } + 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 + 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) + } + } + ) + } +} + + + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Suppress("LongParameterList") +@Composable +private fun ElectricityFormBody( + campusList: List, + selectedCampus: CampusDataItem?, + campusDropdownExpanded: Boolean, + onCampusDropdownChange: (Boolean) -> Unit, + onCampusSelect: (CampusDataItem) -> Unit, + buildingsList: List, + selectedBuilding: CampusDataItem?, + buildingsDropdownExpanded: Boolean, + onBuildingDropdownChange: (Boolean) -> Unit, + onBuildingSelect: (CampusDataItem) -> Unit, + floorsList: List, + selectedFloor: CampusDataItem?, + floorsDropdownExpanded: Boolean, + onFloorDropdownChange: (Boolean) -> Unit, + onFloorSelect: (CampusDataItem) -> Unit, + roomsList: List, + selectedRoom: CampusDataItem?, + roomsDropdownExpanded: Boolean, + onRoomDropdownChange: (Boolean) -> Unit, + onRoomSelect: (CampusDataItem) -> Unit, + historyOptions: List, + roomInfo: String?, + presetCandidates: List, + onPresetVisible: (PresetCandidate) -> Unit, + onPresetApply: (PresetCandidate) -> Unit, + onHistorySelect: (ElectricityDepositHistoryItem) -> Unit, + onInfoClick: () -> Unit, + onInfoLongClick: () -> Unit, + amount: String, + onAmountChange: (String) -> Unit, + onClearFocus: () -> Unit +) { + // RadiantUI 脚手架已自带 16dp 水平边距,经典分支需自行补齐,保证两分支内容逐像素一致 + val hPadding = if (isRadiantUi) 0.dp else 16.dp + + Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { + // 使用最近房间(个性化预设) presetCandidates.firstOrNull()?.let { candidate -> LaunchedEffect(candidate.opportunityId, candidate.presetId) { - viewModel.onPresetCandidateVisible(candidate) + onPresetVisible(candidate) } Text( text = "使用最近房间", modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = hPadding) .clip(SmoothRoundedCornerShape(16.dp)) .background(90.a1 withNight 30.n1) - .clickable { viewModel.applyPresetCandidate(candidate) } + .clickable { onPresetApply(candidate) } .padding(horizontal = 16.dp, vertical = 10.dp), color = 10.n1 withNight 90.n1, style = MaterialTheme.typography.titleMedium ) } - Column( + GlassCard( + containerColor = 100.n1 withNight 20.n1, modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = hPadding) .fillMaxWidth() - .clip(SmoothRoundedCornerShape(16.dp)) - .background(100.n1 withNight 20.n1) ) { + Column { Row( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .padding(16.dp) .fillMaxWidth() - .clickable { campusDropdownExpanded = true }, - - ) { - Text( - text = "选择校区", - style = MaterialTheme.typography.titleMedium - ) - + .clickable { onCampusDropdownChange(true) }, + ) { + Text(text = "选择校区", style = MaterialTheme.typography.titleMedium) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { campusDropdownExpanded = true } + modifier = Modifier.clickable { onCampusDropdownChange(true) } ) { - Text( - text = selectedCampus?.name ?: "请选择校区" - ) - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = "展开校区列表" - ) + 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 }, + onDismissRequest = { onCampusDropdownChange(false) }, ) { campusList.forEach { campus -> DropdownMenuItem( text = { Text(campus.name, color = 10.n1 withNight 90.n1) }, - onClick = { - viewModel.onCampusSelected(campus) - campusDropdownExpanded = false - } + onClick = { onCampusSelect(campus) } ) } } @@ -239,36 +785,27 @@ fun ElectricityDeposit( modifier = Modifier .padding(16.dp) .fillMaxWidth() - .clickable { openBuildingMenu() }, + .clickable { onBuildingDropdownChange(true) }, horizontalArrangement = Arrangement.SpaceBetween, ) { Text(text = "选择楼栋", style = MaterialTheme.typography.titleMedium) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { openBuildingMenu() } + modifier = Modifier.clickable { onBuildingDropdownChange(true) } ) { - Text( - text = selectedBuilding?.name ?: "请选择楼栋" - ) - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = "展开楼栋列表" - ) + 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 }, + onDismissRequest = { onBuildingDropdownChange(false) }, ) { buildingsList.forEach { building -> DropdownMenuItem( text = { Text(building.name, color = 10.n1 withNight 90.n1) }, - onClick = { - viewModel.onBuildingSelected(building) - buildingsDropdownExpanded = false - } + onClick = { onBuildingSelect(building) } ) } } @@ -279,36 +816,27 @@ fun ElectricityDeposit( modifier = Modifier .padding(16.dp) .fillMaxWidth() - .clickable { openFloorMenu() }, + .clickable { onFloorDropdownChange(true) }, horizontalArrangement = Arrangement.SpaceBetween, ) { Text(text = "选择楼层", style = MaterialTheme.typography.titleMedium) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { openFloorMenu() }, + modifier = Modifier.clickable { onFloorDropdownChange(true) }, ) { - Text( - text = selectedFloor?.name ?: "请选择楼层" - ) - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = "展开楼层列表" - ) + 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 }, + onDismissRequest = { onFloorDropdownChange(false) }, ) { floorsList.forEach { floor -> DropdownMenuItem( text = { Text(floor.name, color = 10.n1 withNight 90.n1) }, - onClick = { - viewModel.onfloorSelected(floor) - floorsDropdownExpanded = false - } + onClick = { onFloorSelect(floor) } ) } } @@ -319,42 +847,34 @@ fun ElectricityDeposit( modifier = Modifier .padding(16.dp) .fillMaxWidth() - .clickable { openRoomMenu() }, + .clickable { onRoomDropdownChange(true) }, horizontalArrangement = Arrangement.SpaceBetween, ) { Text(text = "选择房间", style = MaterialTheme.typography.titleMedium) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { openRoomMenu() }, + modifier = Modifier.clickable { onRoomDropdownChange(true) }, ) { - Text( - text = selectedRoom?.name ?: "请选择房间" - ) - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = "展开房间列表" - ) + Text(text = selectedRoom?.name ?: "请选择房间") + Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = "展开房间列表") DropdownMenu( expanded = roomsDropdownExpanded, - onDismissRequest = { roomsDropdownExpanded = false }, + onDismissRequest = { onRoomDropdownChange(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 - } + onClick = { onRoomSelect(room) } ) } } } } + // 上次充值记录快捷入口(仅当有历史时) if (historyOptions.size == 2) { Column( modifier = Modifier @@ -374,7 +894,7 @@ fun ElectricityDeposit( .clip(SmoothRoundedCornerShape(16.dp)) .background(90.a1 withNight 30.n1) .padding(8.dp) - .clickable { viewModel.selectHistory(item) }, + .clickable { onHistorySelect(item) }, color = 10.n1 withNight 90.n1, style = MaterialTheme.typography.bodyMedium ) @@ -387,78 +907,34 @@ fun ElectricityDeposit( 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 - } + onClick = onInfoClick, + onLongClick = onInfoLongClick ), horizontalArrangement = Arrangement.SpaceBetween, ) { Text(text = "信息", style = MaterialTheme.typography.titleMedium) Text(text = roomInfo?.replace(",", "\n") ?: "") } + } } - Column( + GlassCard( + containerColor = 100.n1 withNight 20.n1, modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(16.dp)) - .background(100.n1 withNight 20.n1), + .padding(horizontal = hPadding) + .fillMaxWidth(), ) { - + Column { 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 - } - }, + onValueChange = onAmountChange, modifier = Modifier.fillMaxWidth(), colors = TextFieldDefaults.colors( focusedContainerColor = Color.Transparent, @@ -474,221 +950,11 @@ fun ElectricityDeposit( imeAction = ImeAction.Done ), keyboardActions = KeyboardActions( - onDone = { focusManager.clearFocus() } + onDone = { onClearFocus() } ), singleLine = true ) - } - 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 (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 - ) - } - - 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 - ) - } - } - - is PayState.Succeeded -> { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(24.dp) - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = 100.n1 - ) - Text( - text = "支付成功! 订单号:${(payState.value as PayState.Succeeded).message}", - modifier = Modifier - .padding(4.dp) - .clickable { - - }, - color = 100.n1, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.headlineSmall - ) - } - } - - 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 - ) - } - } - } } } - 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) - } - } - ) - } } -} +} \ No newline at end of file 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..1e146539 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 @@ -1,6 +1,7 @@ package com.ahu.ahutong.ui.screen.main import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -19,10 +20,12 @@ 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.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.outlined.Person import androidx.compose.material3.AlertDialog @@ -58,14 +61,18 @@ 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.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel +import com.ahu.ahutong.R 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.shape.SmoothRoundedCornerShape +import com.ahu.ahutong.ui.components.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.state.EvaluationViewModel import com.kyant.monet.a1 import com.kyant.monet.n1 @@ -98,6 +105,10 @@ fun Evaluation( } } + if (isRadiantUi) { + BackHandler(enabled = currentTask != null) { viewModel.backToList() } + } + if (currentTask != null) { EvaluationFormScreen(viewModel) } else { @@ -105,6 +116,22 @@ fun Evaluation( } } +@Composable +private fun EvalCircleButton( + onClick: () -> Unit, + content: @Composable () -> Unit +) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center + ) { + IconButton(onClick = onClick) { content() } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun EvaluationListScreen(viewModel: EvaluationViewModel) { @@ -174,15 +201,20 @@ private fun EvaluationListScreen(viewModel: EvaluationViewModel) { ) } - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .systemBarsPadding() - .padding(bottom = 96.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Row( + val body: @Composable () -> Unit = { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .systemBarsPadding() + .padding(bottom = 96.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (isRadiantUi) { + Spacer(modifier = Modifier.height(72.dp)) + } + if (!isRadiantUi) { + Row( modifier = Modifier .fillMaxWidth() .padding(start = 24.dp, top = 32.dp, end = 16.dp), @@ -240,6 +272,7 @@ private fun EvaluationListScreen(viewModel: EvaluationViewModel) { tint = 0.n1 withNight 100.n1 ) } + } } OutlinedButton( @@ -321,6 +354,71 @@ private fun EvaluationListScreen(viewModel: EvaluationViewModel) { } } } + } + if (isRadiantUi) { + SecondaryPageScaffold( + title = "教评", + subtitle = semesters.firstOrNull { it.id == selectedSemesterId }?.nameZh, + contentEdgeToEdge = true, + trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box { + EvalCircleButton(onClick = { semesterExpanded = true }) { + Icon( + painter = painterResource(R.drawable.ic_filter), + contentDescription = "选择学期", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + 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 + ) + } + } + } + EvalCircleButton(onClick = { presetDialogShown = true }) { + Icon( + painter = painterResource(R.drawable.ic_config), + contentDescription = "评教预设", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } + ) { body() } + } else { + body() + } } @Composable @@ -455,47 +553,7 @@ private fun EvaluationFormScreen(viewModel: EvaluationViewModel) { } } - Column( - modifier = Modifier - .fillMaxSize() - .systemBarsPadding() - ) { - 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 - ) - } - } - - HorizontalDivider(color = 90.n1 withNight 30.n1, thickness = 0.5.dp) + val body: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit = { if (isLoading && questions.isEmpty()) { Box( @@ -515,6 +573,9 @@ private fun EvaluationFormScreen(viewModel: EvaluationViewModel) { .padding(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { + if (isRadiantUi) { + Spacer(modifier = Modifier.height(72.dp)) + } Spacer(Modifier.height(4.dp)) Row( modifier = Modifier.fillMaxWidth(), @@ -626,6 +687,72 @@ private fun EvaluationFormScreen(viewModel: EvaluationViewModel) { } } } + if (isRadiantUi) { + SecondaryPageScaffold( + title = currentCourseName, + subtitle = "${currentTeacher?.teacherName.orEmpty()} · $currentLessonName", + contentEdgeToEdge = true, + trailingContent = { + EvalCircleButton(onClick = { presetDialogShown = true }) { + Icon( + painter = painterResource(R.drawable.ic_config), + contentDescription = "评教预设", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + ) { + Column(Modifier.fillMaxSize()) { + body() + } + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + ) { + 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 + ) + } + } + + HorizontalDivider(color = 90.n1 withNight 30.n1, thickness = 0.5.dp) + + body() + } + } } @Composable 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..14993018 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 @@ -52,16 +52,22 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp 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.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.SecondarySearchState +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.ExamViewModel import com.ahu.ahutong.ui.state.RefreshState @@ -131,188 +137,269 @@ fun Exam( exam.orEmpty() } - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .systemBarsPadding() - .padding(bottom = 80.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // 标题栏 / 搜索栏 - if (isSearchActive) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = { + if (isRadiantUi) { + // RadiantUI:标题栏套用统一二级页框架(搜索态也在框架内),刷新简化为图标按钮 + SecondaryPageScaffold( + title = stringResource(id = R.string.exam), + trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ExamTitleButton(R.drawable.ic_find, "搜索") { isSearchActive = true } + ExamTitleButton(R.drawable.ic_refresh, "刷新") { + behaviorReporter.organic(AppActionId.MANUAL_REFRESH_EXAM) + examViewModel.loadExam(isRefresh = true) + } + } + }, + search = SecondarySearchState( + query = searchQuery, + visible = isSearchActive, + placeholder = "搜索课程名称…", + onQueryChange = { searchQuery = it }, + onClose = { 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 }) { + }, + onSubmit = {} + ) + ) { + ExamContent( + filteredExams = filteredExams, + isSearchActive = isSearchActive, + searchQuery = searchQuery, + isLoading = isLoading, + horizontal = 0.dp + ) + } + } else { + // Original / Liquid Glass:完整保留原标题栏/搜索栏观感与刷新文本态(冻结不动) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .systemBarsPadding() + .padding(bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (isSearchActive) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { + isSearchActive = false + searchQuery = "" + }) { Icon( - Icons.Default.Search, - contentDescription = "搜索", + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", tint = 0.n1 withNight 100.n1 ) } - RefreshButton(examViewModel) + 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) + } } } + + ExamContent( + filteredExams = filteredExams, + isSearchActive = isSearchActive, + searchQuery = searchQuery, + isLoading = isLoading, + horizontal = 16.dp + ) + } + } +} + +/** 标题栏右侧圆形图标按钮,样式与统一二级页框架一致。 */ +@Composable +private fun ExamTitleButton( + icon: Int, + contentDescription: String, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center + ) { + IconButton(onClick = onClick) { + Icon( + painter = painterResource(icon), + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp) + ) } + } +} - if (isLoading != true) { - if (!filteredExams.isNullOrEmpty()) { - val sortedExams = filteredExams.sortedWith( - compareBy( - { calcTime(it.time) }, - { parseStartTime(it.time) ?: LocalDateTime.MAX } - ) +/** 考试列表内容(卡片 / 已结束折叠 / 空态 / 加载态),Radiant 与经典模式复用。 */ +@Composable +private fun ExamContent( + filteredExams: List, + isSearchActive: Boolean, + searchQuery: String, + isLoading: Boolean?, + horizontal: Dp +) { + if (isLoading != true) { + if (filteredExams.isNotEmpty()) { + val sortedExams = filteredExams.sortedWith( + compareBy( + { calcTime(it.time) }, + { parseStartTime(it.time) ?: LocalDateTime.MAX } ) - // Split into active and finished - val activeExams = sortedExams.filter { calcTime(it.time) != 2 } - val finishedExams = sortedExams.filter { calcTime(it.time) == 2 } - var showFinished by rememberSaveable { mutableStateOf(false) } + ) + // Split into active and finished + val activeExams = sortedExams.filter { calcTime(it.time) != 2 } + val finishedExams = sortedExams.filter { calcTime(it.time) == 2 } + var showFinished by rememberSaveable { mutableStateOf(false) } - Column( - modifier = Modifier.padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Active exams — always visible - activeExams.forEach { examItem -> - ExamCard(examItem = examItem, status = calcTime(examItem.time)) - } + Column( + modifier = Modifier.padding(horizontal = horizontal), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Active exams — always visible + activeExams.forEach { examItem -> + ExamCard(examItem = examItem, status = calcTime(examItem.time)) + } - // Finished exams — collapsible - if (finishedExams.isNotEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(12.dp)) - .background(100.n1 withNight 20.n1) - .clickable { showFinished = !showFinished } - .padding(horizontal = 20.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "已结束 (${finishedExams.size})", - color = 30.n1 withNight 90.n1, - fontSize = 15.sp, - fontWeight = FontWeight.Medium - ) - Icon( - imageVector = if (showFinished) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, - contentDescription = if (showFinished) "收起" else "展开", - tint = 50.n1 withNight 70.n1 - ) - } + // Finished exams — collapsible + if (finishedExams.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(SmoothRoundedCornerShape(12.dp)) + .background(100.n1 withNight 20.n1) + .clickable { showFinished = !showFinished } + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "已结束 (${finishedExams.size})", + color = 30.n1 withNight 90.n1, + fontSize = 15.sp, + fontWeight = FontWeight.Medium + ) + Icon( + imageVector = if (showFinished) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = if (showFinished) "收起" else "展开", + tint = 50.n1 withNight 70.n1 + ) + } - AnimatedVisibility( - visible = showFinished, - enter = expandVertically(), - exit = shrinkVertically() - ) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - finishedExams.forEach { examItem -> - ExamCard(examItem = examItem, status = 2) - } + AnimatedVisibility( + visible = showFinished, + enter = expandVertically(), + exit = shrinkVertically() + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + finishedExams.forEach { examItem -> + ExamCard(examItem = examItem, status = 2) } } } } - } else { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 80.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = when { - isSearchActive && searchQuery.isNotBlank() -> "未找到包含「${searchQuery}」的考试" - else -> "目前没有任何考试" - }, - style = MaterialTheme.typography.bodyLarge, - color = 50.n1 withNight 80.n1 - ) - } } } else { Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = 120.dp), + .padding(vertical = 80.dp), contentAlignment = Alignment.Center ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - CircularProgressIndicator( - modifier = Modifier.size(32.dp), - strokeWidth = 3.dp, - color = 90.a1 withNight 90.a1 - ) - Text( - "加载中…", - color = 50.n1 withNight 80.n1, - fontSize = 14.sp - ) - } + Text( + text = when { + isSearchActive && searchQuery.isNotBlank() -> "未找到包含「${searchQuery}」的考试" + else -> "目前没有任何考试" + }, + style = MaterialTheme.typography.bodyLarge, + color = 50.n1 withNight 80.n1 + ) + } + } + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 120.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + strokeWidth = 3.dp, + color = 90.a1 withNight 90.a1 + ) + Text( + "加载中…", + color = 50.n1 withNight 80.n1, + fontSize = 14.sp + ) } } } @@ -475,4 +562,4 @@ private fun parseStartTime(time: String): LocalDateTime? { val startDateTimeStr = "$datePart ${timeParts[0]}" val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") return runCatching { LocalDateTime.parse(startDateTimeStr, formatter) }.getOrNull() -} +} \ No newline at end of file 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..f0e1a77d 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 @@ -51,6 +51,8 @@ import com.ahu.ahutong.R import com.ahu.ahutong.data.dao.AHUCache import com.ahu.ahutong.data.mock.MockScenarioController import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.ahu.ahutong.ui.components.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.state.FreeClassroomViewModel import com.kyant.capsule.ContinuousCapsule import com.kyant.monet.a1 @@ -100,21 +102,27 @@ fun FreeClassroom( } } - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .systemBarsPadding() - .padding(bottom = 96.dp), - verticalArrangement = Arrangement.Top - ) { - Text( - text = stringResource(id = R.string.free_classroom), + val body: @Composable () -> Unit = { + Column( modifier = Modifier - .fillMaxWidth() - .padding(24.dp, 32.dp), - style = MaterialTheme.typography.headlineMedium - ) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .systemBarsPadding() + .padding(bottom = 96.dp), + verticalArrangement = Arrangement.Top + ) { + + if (isRadiantUi) { + Spacer(modifier = Modifier.height(72.dp)) + } else { + 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) { @@ -392,6 +400,15 @@ fun FreeClassroom( } } } + } + } + if (isRadiantUi) { + SecondaryPageScaffold( + title = stringResource(id = R.string.free_classroom), + contentEdgeToEdge = true + ) { body() } + } else { + body() } } 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..51df3c03 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 @@ -12,11 +12,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search +import androidx.compose.ui.res.painterResource import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -35,6 +37,11 @@ import com.ahu.ahutong.data.mock.MockScenarioController import com.ahu.ahutong.data.model.Grade import com.ahu.ahutong.data.model.GradeStudentProfile import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.ahu.ahutong.ui.components.GlassCard +import com.ahu.ahutong.ui.components.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.SecondarySearchState +import com.ahu.ahutong.ui.components.TrailingAction +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.state.GradeViewModel import com.kyant.capsule.ContinuousCapsule import com.kyant.monet.a1 @@ -123,13 +130,229 @@ fun Grade( } .orEmpty() - Box( + if (isRadiantUi) { + val allTerms = gradeViewModel.grade?.termGradeList + ?.sortedWith( + compareByDescending { + it.schoolYear.substringBefore("-").toIntOrNull() ?: 0 + }.thenByDescending { + it.term.toIntOrNull() ?: 0 + } + ) + .orEmpty() + val selectedTermText = + "${gradeViewModel.schoolYear} 第${gradeViewModel.schoolTerm}学期" + SecondaryPageScaffold( + title = stringResource(id = R.string.grade), + subtitle = selectedTermText, + actions = emptyList(), + trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + GradeTermMenuButton( + allTerms = allTerms, + selectedTermText = selectedTermText, + expanded = termMenuExpanded, + onExpandedChange = { termMenuExpanded = it }, + onSelect = { year, term -> + gradeViewModel.selectTerm(year, term) + termMenuExpanded = false + } + ) + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center + ) { + IconButton( + onClick = { + behaviorReporter.organic(AppActionId.MANUAL_REFRESH_GRADE) + gradeViewModel.refreshGrade() + } + ) { + Icon( + painter = painterResource(R.drawable.ic_refresh), + contentDescription = "刷新成绩", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp) + ) + } + } + } + }, + content = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + if (!searchExpanded && gradeViewModel.studentProfiles.size > 1) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + gradeViewModel.studentProfiles.forEachIndexed { index, profile -> + FilterChip( + selected = gradeViewModel.selectedProfileIndex == index, + onClick = { gradeViewModel.selectProfile(index) }, + label = { + Text( + text = profile.displayName, + style = MaterialTheme.typography.labelMedium + ) + }, + 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 + ) + } + } + } + + if (!searchExpanded) { + gradeViewModel.presetCandidates.firstOrNull()?.let { candidate -> + LaunchedEffect(candidate.opportunityId, candidate.presetId) { + gradeViewModel.onPresetCandidateVisible(candidate) + } + Text( + text = "使用常用条件", + modifier = Modifier + .clip(ContinuousCapsule) + .background(90.a1) + .clickable { gradeViewModel.applyPresetCandidate(candidate) } + .padding(horizontal = 16.dp, vertical = 10.dp), + color = 0.n1, + style = MaterialTheme.typography.titleMedium + ) + } + } + + when { + trimmedQuery.isNotBlank() -> { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + searchResultsByTerm.forEach { (term, items) -> + Text( + text = "${term.schoolYear} 第${term.term}学期", + color = 0.n1 withNight 100.n1, + style = MaterialTheme.typography.titleMedium + ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items.forEach { item -> + GradeCard( + item = item, + onNavigateToEvaluation = onNavigateToEvaluation + ) + } + } + } + } + } + + gradeData != null && gradeData.gradeList.isNotEmpty() -> { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + if (!searchExpanded) { + GlassCard( + containerColor = 100.n1 withNight 20.n1, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + val rankMsg = gradeViewModel.rankEmptyMessage + if (gpaRankInfo == null && !rankMsg.isNullOrBlank()) { + Text( + text = rankMsg, + style = MaterialTheme.typography.titleMedium, + color = 50.n1 withNight 70.n1 + ) + } + val infoList = listOf( + "本学期平均绩点" to gradeViewModel.termGradePointAverage, + "全程平均绩点" to gradeViewModel.totalGradePointAverage, + "全程专业排名" to ((gpaRankInfo?.majorRank ?: "暂无").toString() + "/" + (gpaRankInfo?.majorHeadCount ?: "暂无")), + "该学期专业排名" to ((currentRank?.majorRank ?: "暂无").toString() + "/" + (gpaRankInfo?.majorHeadCount ?: "暂无")), + "最后更新时间" to (gpaRankInfo?.updatedDateTimeStr ?: "暂无") + ) + infoList.forEach { (title, value) -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + color = 0.n1 withNight 100.n1, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = value, + color = 0.n1 withNight 100.n1, + style = MaterialTheme.typography.titleMedium + ) + } + } + } + } + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + gradeData.gradeList.forEach { item -> + GradeCard( + item = item, + onNavigateToEvaluation = onNavigateToEvaluation + ) + } + } + } + } + + else -> { + val emptyMsg = if (gradeViewModel.studentProfiles.size > 1) { + val p = gradeViewModel.studentProfiles.getOrNull(gradeViewModel.selectedProfileIndex) + if (p != null) "「${p.displayName}」暂无成绩" else "该学期目前没有任何成绩" + } else { + "该学期目前没有任何成绩" + } + Text( + text = emptyMsg, + modifier = Modifier.padding(24.dp), + style = MaterialTheme.typography.titleLarge, + color = 50.n1 withNight 70.n1 + ) + } + } + } + } + ) + } else { + Box( modifier = Modifier .fillMaxSize() .systemBarsPadding() - ) { - Column( - modifier = Modifier + ) { + Column( + modifier = Modifier .fillMaxSize() .verticalScroll(scrollState) .padding(bottom = 96.dp), @@ -427,6 +650,53 @@ fun Grade( } } } + } +} + +@Composable +private fun GradeTermMenuButton( + allTerms: List, + selectedTermText: String, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onSelect: (String, String) -> Unit +) { + Box { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center + ) { + IconButton(onClick = { onExpandedChange(!expanded) }) { + Icon( + painter = painterResource(R.drawable.ic_filter), + contentDescription = "选择学期:$selectedTermText", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp) + ) + } + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { onExpandedChange(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 = { onSelect(term.schoolYear, term.term) } + ) + } + } + } } @Composable @@ -439,12 +709,14 @@ private fun GradeCard( val gradeText = item.grade.stripHtml() val gradeDetail = item.gradeDetail.stripHtml() + val gradeCardShape = if (isRadiantUi) SmoothRoundedCornerShape(16.dp) else SmoothRoundedCornerShape(4.dp) + val gradeCardPadding = if (isRadiantUi) PaddingValues(20.dp, 16.dp) else PaddingValues(24.dp, 16.dp) Column( modifier = Modifier .fillMaxWidth() - .clip(SmoothRoundedCornerShape(4.dp)) + .clip(gradeCardShape) .background(100.n1 withNight 20.n1) - .padding(24.dp, 16.dp), + .padding(gradeCardPadding), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( 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..cd8a4bc5 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 @@ -8,14 +8,19 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -39,6 +44,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.zIndex import androidx.compose.ui.geometry.Rect import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput @@ -59,7 +66,11 @@ import com.ahu.ahutong.data.debug.DebugClock 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.GlassBackdropContainer +import com.ahu.ahutong.ui.components.LocalIsLiquidGlassEnabled +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.screen.main.home.AtAGlance +import com.ahu.ahutong.ui.screen.main.home.HomeDateRow import com.ahu.ahutong.ui.screen.main.home.HomeWeatherWidget import com.ahu.ahutong.ui.screen.main.home.HomeWidgetDragOverlay import com.ahu.ahutong.ui.screen.main.home.HomeWidgetLibrarySheet @@ -118,10 +129,18 @@ fun Home( } else { emptyList() } + val radiant = isRadiantUi + val slotCount = if (radiant) HomeWidgetRegistry.slotCountRadiant else HomeWidgetRegistry.slotCountClassic + val knownIds = HomeWidgetRegistry.availableWidgets(radiant).map { it.id }.toSet() var currentMinutes by remember { mutableIntStateOf(DebugClock.currentMinutes()) } var isEditingHome by remember { mutableStateOf(false) } - var homeWidgetSlots by remember { - mutableStateOf(normalizeHomeWidgetSlots(AHUCache.getHomeWidgetSlots())) + var homeWidgetSlots by remember(radiant) { + val baseSlots = if (radiant && !AHUCache.hasCustomHomeWidgetSlots()) { + HomeWidgetRegistry.defaultSlotsRadiant + } else { + AHUCache.getHomeWidgetSlots() + } + mutableStateOf(normalizeHomeWidgetSlots(baseSlots, slotCount, knownIds)) } val slotBounds = remember { mutableStateMapOf() } var libraryBounds by remember { mutableStateOf(null) } @@ -133,13 +152,14 @@ fun Home( drag = it, slots = homeWidgetSlots, slotBounds = slotBounds, - dropSlopPx = dropSlopPx + dropSlopPx = dropSlopPx, + slotCount = slotCount ) } val weatherHomeConfig = WeatherHomeConfig.fromCache() fun saveHomeWidgetSlots(slots: List) { - val normalizedSlots = normalizeHomeWidgetSlots(slots) + val normalizedSlots = normalizeHomeWidgetSlots(slots, slotCount, knownIds) homeWidgetSlots = normalizedSlots AHUCache.saveHomeWidgetSlots(normalizedSlots) } @@ -172,7 +192,8 @@ fun Home( drag = drag, slots = homeWidgetSlots, slotBounds = slotBounds, - dropSlopPx = dropSlopPx + dropSlopPx = dropSlopPx, + slotCount = slotCount ) if (drag.sourceSlot != null && libraryBounds?.contains(dragCenter) == true) { @@ -296,6 +317,24 @@ fun Home( exitHomeEditMode() } } + val trailing: @Composable RowScope.() -> Unit = { + if (BuildConfig.DEBUG) { + DebugBuildBadge() + } + if ( + !isEditingHome && + weatherHomeConfig.showOnHome && + weatherHomeConfig.mode == WeatherHomeMode.Compact + ) { + HomeWeatherWidget( + onClick = { navController.navigate("weather") }, + modifier = Modifier.padding(start = 12.dp), + config = weatherHomeConfig, + mode = WeatherHomeMode.Compact + ) + } + } + GlassBackdropContainer(modifier = Modifier.fillMaxSize()) { backdrop -> Box( modifier = Modifier .fillMaxSize() @@ -344,8 +383,11 @@ fun Home( .fillMaxSize() .verticalScroll(rememberScrollState()) .systemBarsPadding() - .padding(bottom = if (isEditingHome) 520.dp else 96.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) + .padding( + top = if (radiant) 48.dp else 0.dp, + bottom = if (isEditingHome) 520.dp else 96.dp + ), + verticalArrangement = if (radiant) Arrangement.Center else Arrangement.spacedBy(24.dp) ) { AtAGlance( todayCourses = todayCourses, @@ -353,25 +395,12 @@ fun Home( navController = navController, isInSemester = isInSemester, enabled = !isEditingHome, - trailingContent = { - if (BuildConfig.DEBUG) { - DebugBuildBadge() - } - if ( - !isEditingHome && - weatherHomeConfig.showOnHome && - weatherHomeConfig.mode == WeatherHomeMode.Compact - ) { - HomeWeatherWidget( - onClick = { navController.navigate("weather") }, - modifier = Modifier.padding(start = 12.dp), - config = weatherHomeConfig, - mode = WeatherHomeMode.Compact - ) - } - } + trailingContent = trailing ) + + if (radiant) Spacer(modifier = Modifier.height(12.dp)) if (todayCourses.isNotEmpty()) { + if (radiant) Spacer(modifier = Modifier.height(16.dp)) SlideInContent(visible = 0 in discoveryViewModel.visibilities) { TodayCourseList( todayCourses = todayCourses, @@ -381,7 +410,9 @@ fun Home( ) } } + if (weatherHomeConfig.showOnHome && weatherHomeConfig.mode == WeatherHomeMode.Detailed) { + if (radiant) Spacer(modifier = Modifier.height(20.dp)) SlideInContent(visible = !isEditingHome) { HomeWeatherWidget( onClick = { navController.navigate("weather") }, @@ -390,12 +421,15 @@ fun Home( ) } } + + if (radiant) Spacer(modifier = Modifier.height(8.dp)) SlideInContent(visible = 1 in discoveryViewModel.visibilities) { HomeWidgetSlotLayout( balance = discoveryViewModel.balance, transitionBalance = discoveryViewModel.transitionBalance, onRefreshBalance = discoveryViewModel::refreshCardBalance, navController = navController, + backdrop = backdrop, slots = homeWidgetSlots, isEditing = isEditingHome, highlightedSlot = highlightedSlot, @@ -418,8 +452,38 @@ fun Home( } } + if (radiant) { + val headerBg = if (LocalIsLiquidGlassEnabled.current) { + MaterialTheme.colorScheme.surfaceContainerLowest + } else { + MaterialTheme.colorScheme.surface + } + Box( + modifier = Modifier + .fillMaxWidth() + .zIndex(20f) + .background( + Brush.verticalGradient( + colorStops = arrayOf( + 0f to headerBg, + 0.35f to headerBg, + 0.68f to headerBg.copy(alpha = 0.85f), + 1f to headerBg.copy(alpha = 0f) + ) + ) + ) + .statusBarsPadding() + .padding(top = 12.dp) + ) { + HomeDateRow( + trailingContent = trailing + ) + Spacer(modifier = Modifier.height(24.dp)) + } + } + val placedWidgetIds = homeWidgetSlots.filterNotNull().toSet() - val availableWidgets = HomeWidgetRegistry.widgets.filter { it.id !in placedWidgetIds } + val availableWidgets = HomeWidgetRegistry.availableWidgets(radiant).filter { it.id !in placedWidgetIds } val isDraggingFromLibrary = activeDrag != null && activeDrag?.sourceSlot == null HomeWidgetLibrarySheet( visible = isEditingHome, @@ -470,11 +534,13 @@ fun Home( spec = spec, topLeft = previewTopLeft, size = previewSize, - rootTopLeft = rootTopLeft + rootTopLeft = rootTopLeft, + backdrop = backdrop ) } } } + } } @Composable @@ -506,10 +572,13 @@ private fun DebugBuildBadge() { } } -private fun normalizeHomeWidgetSlots(slots: List): List { - val knownIds = HomeWidgetRegistry.widgetById.keys +private fun normalizeHomeWidgetSlots( + slots: List, + slotCount: Int, + knownIds: Set +): List { val seen = mutableSetOf() - return List(HomeWidgetRegistry.slotCount) { index -> + return List(slotCount) { index -> val id = slots.getOrNull(index)?.takeIf { it in knownIds } if (id != null && seen.add(id)) id else null } @@ -519,11 +588,12 @@ private fun findHomeWidgetDropSlot( drag: ActiveHomeWidgetDrag, slots: List, slotBounds: Map, - dropSlopPx: Float + dropSlopPx: Float, + slotCount: Int ): Int? { val center = drag.center return slotBounds - .filterKeys { it in 1..HomeWidgetRegistry.slotCount } + .filterKeys { it in 1..slotCount } .mapNotNull { (slotIndex, bounds) -> if (!bounds.expandedBy(dropSlopPx).contains(center)) return@mapNotNull null if (drag.sourceSlot == null && slots.getOrNull(slotIndex - 1) != null) return@mapNotNull null 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..d0f768ec 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 @@ -12,6 +12,7 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Refresh @@ -24,6 +25,8 @@ 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.painterResource +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 @@ -33,10 +36,13 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.hilt.navigation.compose.hiltViewModel import coil.compose.AsyncImage +import com.ahu.ahutong.R 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.shape.SmoothRoundedCornerShape +import com.ahu.ahutong.ui.components.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.state.LostFoundViewModel import com.kyant.capsule.ContinuousCapsule import com.kyant.monet.a1 @@ -242,7 +248,8 @@ fun LostFound( } } - Box( + val body: @Composable () -> Unit = { + Box( modifier = Modifier .fillMaxSize() .systemBarsPadding() @@ -254,7 +261,7 @@ fun LostFound( verticalArrangement = Arrangement.spacedBy(24.dp), contentPadding = - PaddingValues(bottom = 96.dp) + PaddingValues(top = if (isRadiantUi) 72.dp else 0.dp, bottom = 96.dp) ) { item { @@ -323,6 +330,7 @@ fun LostFound( modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd ) { + if (!isRadiantUi) { Row( modifier = Modifier .clip(ContinuousCapsule) @@ -369,9 +377,10 @@ fun LostFound( else Icons.Default.Search, contentDescription = null - ) + ) } } + } } } if (searchExpanded) { @@ -822,6 +831,46 @@ fun LostFound( } } } + } + if (isRadiantUi) { + SecondaryPageScaffold( + title = stringResource(id = R.string.lost_found), + contentEdgeToEdge = true, + trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + LostFoundTitleButton(onClick = { + lostFoundViewModel.refreshList() + Toast.makeText(context, "刷新成功", Toast.LENGTH_SHORT).show() + }) { + Icon(painterResource(R.drawable.ic_refresh), contentDescription = "刷新", modifier = Modifier.size(18.dp)) + } + LostFoundTitleButton(onClick = { + searchExpanded = !searchExpanded + if (!searchExpanded) searchQuery = "" + }) { + if (searchExpanded) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "搜索", + modifier = Modifier.size(18.dp) + ) + } else { + Icon( + painter = painterResource(R.drawable.ic_find), + contentDescription = "搜索", + modifier = Modifier.size(18.dp) + ) + } + } + } + } + ) { body() } + } else { + body() + } /** * 全屏图片查看器 @@ -1221,3 +1270,19 @@ fun LostFound( } } } + +@Composable +private fun LostFoundTitleButton( + onClick: () -> Unit, + content: @Composable () -> Unit +) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center + ) { + IconButton(onClick = onClick) { content() } + } +} diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/MoreWidgetsScreen.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/MoreWidgetsScreen.kt new file mode 100644 index 00000000..3f4af1c5 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/MoreWidgetsScreen.kt @@ -0,0 +1,104 @@ +package com.ahu.ahutong.ui.screen.main + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +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.padding +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.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Edit +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.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import com.ahu.ahutong.R +import com.ahu.ahutong.data.dao.AHUCache +import com.ahu.ahutong.ui.components.isRadiantUi +import com.ahu.ahutong.ui.screen.main.home.HomeWidgetRegistry + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MoreWidgetsScreen( + navController: NavHostController, + homeEditEnabled: Boolean = false, + onEditHome: () -> Unit = {} +) { + val homeWidgetIds = remember { AHUCache.getHomeWidgetSlots().filterNotNull().toSet() } + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .systemBarsPadding() + .padding(bottom = 96.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, top = 20.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "返回" + ) + } + Text( + text = "全部小工具", + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.headlineMedium + ) + if (homeEditEnabled) { + IconButton( + onClick = { + onEditHome() + navController.navigate("home") { + popUpTo("home") { + inclusive = false + } + launchSingleTop = true + } + } + ) { + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = "编辑首页" + ) + } + } + } + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + HomeWidgetRegistry.availableWidgets(isRadiantUi) + .filter { it.id !in homeWidgetIds } + .forEach { widget -> + ToolItem( + title = widget.title, + iconId = widget.iconId, + tint = widget.tint, + onClick = { navController.navigate(widget.route) } + ) + } + } + } +} \ No newline at end of file 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..a895c991 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 @@ -55,6 +55,9 @@ 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.GlassCard +import com.ahu.ahutong.ui.components.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.NetworkRechargePageState import com.ahu.ahutong.ui.state.NetworkRechargeUiData @@ -96,18 +99,82 @@ fun NetworkRecharge( } } - Column( + if (isRadiantUi) { + SecondaryPageScaffold( + title = "网费充值", + content = { + Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { + when (val state = pageState) { + NetworkRechargePageState.Loading -> LoadingCard() + is NetworkRechargePageState.Error -> ErrorCard( + message = state.message, + onRetry = { viewModel.load() } + ) + is NetworkRechargePageState.Ready -> { + NetworkAccountCard(data = state.data) + AmountCard( + amount = amount, + amountError = amountError, + quickAmounts = state.data.quickAmounts, + maxAmount = state.data.maxAmount, + onAmountChange = { value -> + if (value.isEmpty()) { + amount = value + amountError = null + return@AmountCard + } + val regex = Regex("^\\d*\\.?\\d{0,2}$") + if (regex.matches(value)) { + amount = value + amountError = null + } + }, + onQuickAmountClick = { quickAmount -> + amount = normalizeQuickAmount(quickAmount) + amountError = null + }, + onDone = { focusManager.clearFocus() } + ) + + RechargeActionRow( + payState = payState, + onConfirm = { + focusManager.clearFocus() + val amountValue = amount.toDoubleOrNull() + val maxAmount = state.data.maxAmount?.toDoubleOrNull() + amountError = when { + amount.isBlank() -> "请输入充值金额" + amountValue == null || amountValue <= 0.0 -> "请输入有效金额" + maxAmount != null && amountValue > maxAmount -> + "单次最高可充值 ${state.data.maxAmount} 元" + + else -> null + } + if (amountError == null) { + password = "" + passwordError = null + showDialog = true + } + } + ) + } + } + } + } + ) + } else { + 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 - ) + ) { + Text( + text = "网费充值", + modifier = Modifier.padding(24.dp, 32.dp), + style = MaterialTheme.typography.headlineMedium + ) when (val state = pageState) { NetworkRechargePageState.Loading -> { @@ -170,6 +237,7 @@ fun NetworkRecharge( } } } + } if (showDialog) { AlertDialog( @@ -241,16 +309,32 @@ fun NetworkRecharge( @Composable private fun LoadingCard() { - Box( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(24.dp)) - .background(100.n1 withNight 20.n1) - .padding(24.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = 30.n1 withNight 70.n1) + if (isRadiantUi) { + GlassCard( + containerColor = 100.n1 withNight 20.n1, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = 30.n1 withNight 70.n1) + } + } + } else { + Box( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .clip(SmoothRoundedCornerShape(24.dp)) + .background(100.n1 withNight 20.n1) + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = 30.n1 withNight 70.n1) + } } } @@ -259,26 +343,50 @@ private fun ErrorCard( message: String, onRetry: () -> Unit ) { - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(24.dp)) - .background(100.n1 withNight 20.n1) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = message, - color = 10.n1 withNight 90.n1, - style = MaterialTheme.typography.bodyLarge - ) - Text( - text = "重试", - modifier = Modifier.clickable(onClick = onRetry), - color = 30.n1 withNight 70.n1, - style = MaterialTheme.typography.titleMedium - ) + if (isRadiantUi) { + GlassCard( + containerColor = 100.n1 withNight 20.n1, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = message, + color = 10.n1 withNight 90.n1, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "重试", + modifier = Modifier.clickable(onClick = onRetry), + color = 30.n1 withNight 70.n1, + style = MaterialTheme.typography.titleMedium + ) + } + } + } else { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .clip(SmoothRoundedCornerShape(24.dp)) + .background(100.n1 withNight 20.n1) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = message, + color = 10.n1 withNight 90.n1, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "重试", + modifier = Modifier.clickable(onClick = onRetry), + color = 30.n1 withNight 70.n1, + style = MaterialTheme.typography.titleMedium + ) + } } } @@ -286,49 +394,97 @@ private fun ErrorCard( private fun NetworkAccountCard( data: NetworkRechargeUiData ) { - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(24.dp)) - .background(100.n1 withNight 20.n1) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = data.feeName, - style = MaterialTheme.typography.titleLarge - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + if (isRadiantUi) { + GlassCard( + containerColor = 100.n1 withNight 20.n1, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = data.feeName, + style = MaterialTheme.typography.titleLarge + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "充值账号", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = data.account.ifBlank { "--" }, + color = 10.n1 withNight 90.n1, + style = MaterialTheme.typography.bodyLarge + ) + } + data.stats.forEach { (label, value) -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + color = 40.n1 withNight 60.n1, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = value.ifBlank { "--" }, + color = 10.n1 withNight 90.n1, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + } else { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .clip(SmoothRoundedCornerShape(24.dp)) + .background(100.n1 withNight 20.n1) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( - text = "充值账号", - style = MaterialTheme.typography.titleMedium - ) - Text( - text = data.account.ifBlank { "--" }, - color = 10.n1 withNight 90.n1, - style = MaterialTheme.typography.bodyLarge + text = data.feeName, + style = MaterialTheme.typography.titleLarge ) - } - data.stats.forEach { (label, value) -> Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = label, - color = 40.n1 withNight 60.n1, - style = MaterialTheme.typography.bodyMedium + text = "充值账号", + style = MaterialTheme.typography.titleMedium ) Text( - text = value.ifBlank { "--" }, + text = data.account.ifBlank { "--" }, color = 10.n1 withNight 90.n1, - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyLarge ) } + data.stats.forEach { (label, value) -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + color = 40.n1 withNight 60.n1, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = value.ifBlank { "--" }, + color = 10.n1 withNight 90.n1, + style = MaterialTheme.typography.bodyMedium + ) + } + } } } } @@ -343,74 +499,146 @@ private fun AmountCard( onQuickAmountClick: (String) -> Unit, onDone: () -> Unit ) { - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(24.dp)) - .background(100.n1 withNight 20.n1) - ) { - Text( - text = "充值金额", - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.titleMedium - ) + if (isRadiantUi) { + GlassCard( + containerColor = 100.n1 withNight 20.n1, + modifier = Modifier.fillMaxWidth() + ) { + Column { + Text( + text = "充值金额", + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.titleMedium + ) - if (quickAmounts.isNotEmpty()) { - FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - quickAmounts.forEach { quickAmount -> - Text( - text = quickAmount, + if (quickAmounts.isNotEmpty()) { + FlowRow( 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 + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + 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 + ) + } + } + } + + TextField( + 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), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { onDone() }), + singleLine = true + ) + + amountError?.let { + Text( + text = it, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall ) } } } + } else { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .clip(SmoothRoundedCornerShape(24.dp)) + .background(100.n1 withNight 20.n1) + ) { + Text( + text = "充值金额", + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.titleMedium + ) - TextField( - 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), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Decimal, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions(onDone = { onDone() }), - singleLine = true - ) + if (quickAmounts.isNotEmpty()) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + 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 + ) + } + } + } - amountError?.let { - Text( - text = it, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall + TextField( + 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), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { onDone() }), + singleLine = true ) + + amountError?.let { + Text( + text = it, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } } } } 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..3a0d6aec 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 @@ -8,14 +8,18 @@ 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.ColumnScope import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.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.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -43,14 +47,19 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign 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.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.SecondarySearchState +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.TelDirectoryViewModel import com.kyant.capsule.ContinuousCapsule @@ -86,85 +95,27 @@ fun PhoneBook() { } } - Column( - modifier = Modifier - .fillMaxSize() - .systemBarsPadding() - ) { - 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( - 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 - ) - } - } 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 - ) - } - } - } - } + val searchState = SecondarySearchState( + query = searchQuery, + visible = isSearchActive, + placeholder = "搜索电话或部门", + onQueryChange = { searchQuery = it }, + onClose = { + isSearchActive = false + searchQuery = "" + }, + onSubmit = { } + ) + val body: @Composable ColumnScope.() -> Unit = { if (isSearchActive) { LazyColumn( modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + top = if (isRadiantUi) 84.dp else 0.dp + ), verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (searchResults.isEmpty() && searchQuery.isNotEmpty()) { @@ -206,6 +157,9 @@ fun PhoneBook() { .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(24.dp) ) { + if (isRadiantUi) { + Spacer(modifier = Modifier.height(84.dp)) + } Categories( selectedCategory = selectedCategory, onCategorySelected = { selectedCategory = it } @@ -228,12 +182,142 @@ fun PhoneBook() { } } } + + if (isRadiantUi) { + SecondaryPageScaffold( + title = stringResource(id = R.string.phone_book), + search = searchState, + trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TitleBarIconButton( + icon = R.drawable.ic_find, + contentDescription = "搜索", + onClick = { isSearchActive = true } + ) + } + }, + contentEdgeToEdge = true + ) { + Column( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + ) { + body() + } + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() + ) { + 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( + 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 + ) + } + } 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 + ) + } + } + } + } + + body() + } + } DialDialog( onDismiss = { dialData = null }, tel = dialData ) } +/** 标题栏右侧圆形图标按钮,样式与统一二级页框架一致。 */ +@Composable +private fun TitleBarIconButton( + icon: Int, + contentDescription: String, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center + ) { + IconButton(onClick = onClick) { + Icon( + painter = painterResource(icon), + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp) + ) + } + } +} + @Composable private fun TelItem( tel: Tel, 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..2d7612b0 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 @@ -1,4 +1,4 @@ -package com.ahu.ahutong.ui.screen.main +package com.ahu.ahutong.ui.screen.main import android.content.Context import android.content.Intent @@ -12,11 +12,13 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope 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.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding @@ -25,6 +27,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -52,6 +55,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -61,11 +65,14 @@ import androidx.compose.ui.window.DialogProperties import androidx.compose.foundation.isSystemInDarkTheme import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController +import com.ahu.ahutong.R 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.state.RepositoryMarkdownUiState import com.ahu.ahutong.ui.state.RepositoryViewModel +import com.ahu.ahutong.ui.components.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.isRadiantUi import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.withNight @@ -127,54 +134,10 @@ fun Repository( } } - Column( - 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 = "返回" - ) - } - 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( - imageVector = Icons.Outlined.Download, - contentDescription = "已下载", - tint = MaterialTheme.colorScheme.primary - ) - } - IconButton(onClick = { navController.navigate("repository_settings") }) { - Icon( - imageVector = Icons.Outlined.Tune, - contentDescription = "学习资料设置" - ) - } + val body: @Composable ColumnScope.() -> Unit = { + if (isRadiantUi) { + Spacer(modifier = Modifier.height(72.dp)) } - RepositoryBreadcrumb( currentPath = path, onPathClick = { targetPath -> @@ -291,6 +254,104 @@ fun Repository( } } + if (isRadiantUi) { + SecondaryPageScaffold( + title = "学习资料", + contentEdgeToEdge = true, + trailingContent = { + RepositoryTitleButton( + loading = state.isLoading || state.isRefreshing || sharedState.isCacheWarming, + onClick = { + behaviorReporter.organic( + if (state.error == null) AppActionId.MANUAL_REFRESH_REPOSITORY + else AppActionId.RETRY_REPOSITORY + ) + viewModel.refreshDirectory(path) + } + ) { + Icon( + painter = painterResource(R.drawable.ic_refresh), + contentDescription = "刷新", + modifier = Modifier.size(18.dp) + ) + } + RepositoryTitleButton(onClick = { navController.navigate("repository_downloads") }) { + Icon( + painter = painterResource(R.drawable.ic_download), + contentDescription = "已下载", + modifier = Modifier.size(18.dp) + ) + } + RepositoryTitleButton(onClick = { navController.navigate("repository_settings") }) { + Icon( + painter = painterResource(R.drawable.ic_config), + contentDescription = "学习资料设置", + modifier = Modifier.size(18.dp) + ) + } + } + ) { + Column( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + ) { + body() + } + } + } else { + Column( + 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 = "返回" + ) + } + 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( + imageVector = Icons.Outlined.Download, + contentDescription = "已下载", + tint = MaterialTheme.colorScheme.primary + ) + } + IconButton(onClick = { navController.navigate("repository_settings") }) { + Icon( + imageVector = Icons.Outlined.Tune, + contentDescription = "学习资料设置" + ) + } + } + + body() + } + } + RepositoryMarkdownReader( markdownState = markdownState, onDismiss = { viewModel.clearMarkdown() } @@ -420,6 +481,32 @@ private fun RepositoryRefreshButton( } } +@Composable +private fun RepositoryTitleButton( + loading: Boolean = false, + onClick: () -> Unit, + content: @Composable () -> Unit +) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center + ) { + IconButton(onClick = onClick, enabled = !loading) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp + ) + } else { + content() + } + } + } +} + @Composable private fun RepositoryBreadcrumb( currentPath: String, 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..35a6eba7 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 @@ -13,13 +13,16 @@ import androidx.compose.foundation.layout.BoxScope 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState @@ -49,17 +52,24 @@ 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.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer 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.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel +import com.ahu.ahutong.R import com.ahu.ahutong.data.model.Course import com.ahu.ahutong.ui.screen.main.schedule.CourseCard import com.ahu.ahutong.ui.screen.main.schedule.CourseCardSpec @@ -67,6 +77,11 @@ import com.ahu.ahutong.ui.screen.main.schedule.CourseDetailDialog 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.components.isRadiantUi +import com.ahu.ahutong.ui.components.LocalIsLiquidGlassEnabled +import com.ahu.ahutong.ui.components.GlassBackdropContainer +import com.ahu.ahutong.ui.components.liquidGlassSurface +import com.ahu.ahutong.ui.components.liquidGlassTint import com.ahu.ahutong.ui.state.ScheduleViewModel import com.kyant.capsule.ContinuousCapsule import com.kyant.monet.Hct.Companion.toHct @@ -184,16 +199,40 @@ fun Schedule( } val baseColor = 50.a1.toSrgb().toHct() + val radiant = isRadiantUi + // Radiant:清新色库(用户指定的 11 个中等饱和度柔和色) + val macaronPalette = remember { + listOf( + Color(0xFF82ADF7), Color(0xFF7AE3D2), Color(0xFF77B6EF), Color(0xFFE19BB0), + Color(0xFFE38874), Color(0xFF679ACD), Color(0xFFE87897), Color(0xFFEBB877), + Color(0xFFC8A2C8), Color(0xFFA8E4A0), Color(0xFFFF8A80) + ) + } 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() + if (radiant) { + // Radiant 分支:马卡龙库分配。课程数 ≤ 库大小时不重复(按库顺序取), + // 超出库后按课程名哈希取色(允许撞色,同一课程名恒定同色)。 + val names = schedule.map { it.name }.distinct() + names.mapIndexed { index, name -> + val color = if (index < macaronPalette.size) { + macaronPalette[index] + } else { + macaronPalette[((name?.hashCode() ?: 0).mod(macaronPalette.size))] + } + name to color }.toMap() + } else { + // 冻结分支:HCT 均匀色相旋转(原逻辑) + 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() + } ) } @@ -201,318 +240,713 @@ fun Schedule( var detailedCourse by rememberSaveable { mutableStateOf(null) } val settingsCardColor = 100.n1 withNight 20.n1 - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .systemBarsPadding() - .padding(bottom = 96.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row( - modifier = Modifier.padding(end = 8.dp), - ) { - // week selector - LazyRow( - modifier = Modifier.weight(1f), - state = state, - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) + if (isRadiantUi) { + // RadiantUI 课表:GlassBackdropContainer 提供玻璃采样层(液态玻璃开关下带渐变色带), + // 固定标题栏(渐变遮罩)+ 网格卡(液态玻璃 + 阴影)复用主页校园卡同款材质。 + val headerBg = if (LocalIsLiquidGlassEnabled.current) { + MaterialTheme.colorScheme.surfaceContainerLowest + } else { + 96.n1 withNight 10.n1 + } + GlassBackdropContainer(modifier = Modifier.fillMaxSize()) { backdrop -> + Box( + modifier = Modifier + .fillMaxSize() ) { - items(20) { - val week = it + 1 - val isSelected = week == currentWeek - CompositionLocalProvider( - LocalIndication provides ripple( - color = if (isSelected) { - 100.n1 withNight 0.n1 - } else { - 0.n1 withNight 100.n1 - } + // 固定标题栏(渐变遮罩层):与内容区为兄弟叠加关系,zIndex 盖在可穿透内容之上 + Column( + modifier = Modifier + .fillMaxWidth() + .background( + Brush.verticalGradient( + colorStops = arrayOf( + 0f to headerBg, + 0.35f to headerBg, + 0.68f to headerBg.copy(alpha = 0.85f), + 1f to headerBg.copy(alpha = 0f) + ) ) + ) + .statusBarsPadding() + .zIndex(20f) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // week selector + LazyRow( + modifier = Modifier.weight(1f), + state = state, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - text = week.toString(), - modifier = Modifier - .clip(ContinuousCapsule) - .background( - animateColorAsState( + items(20) { + val week = it + 1 + val isSelected = week == currentWeek + CompositionLocalProvider( + LocalIndication provides ripple( + color = if (isSelected) { + 100.n1 withNight 0.n1 + } else { + 0.n1 withNight 100.n1 + } + ) + ) { + Text( + text = week.toString(), + modifier = Modifier + .clip(ContinuousCapsule) + .background( + animateColorAsState( + targetValue = if (isSelected) { + 40.a1 withNight 90.a1 + } else { + Color.Transparent + } + ).value + ) + .clickable { + if (currentWeek != week) { + behaviorRuntime.recordCommittedMutationAsync( + MutationId.SCHEDULE_WEEK_CHANGED, + currentWeek, + week, + coarseValueBucket = if (week == scheduleConfig?.week) "CURRENT_WEEK" else "OTHER_WEEK" + ) + } + scope.launch { + pagerState.animateScrollToPage(week - 1) + } + } + .padding(16.dp, 8.dp), + color = animateColorAsState( targetValue = if (isSelected) { - 40.a1 withNight 90.a1 + 100.n1 withNight 0.n1 } else { - Color.Transparent + 0.n1 withNight 100.n1 } - ).value + ).value, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium ) - .clickable { - if (currentWeek != week) { - behaviorRuntime.recordCommittedMutationAsync( - MutationId.SCHEDULE_WEEK_CHANGED, - currentWeek, - week, - coarseValueBucket = if (week == scheduleConfig?.week) "CURRENT_WEEK" else "OTHER_WEEK" - ) - } - scope.launch { - pagerState.animateScrollToPage(week - 1) - } - } - .padding(16.dp, 8.dp), - color = animateColorAsState( - targetValue = if (isSelected) { - 100.n1 withNight 0.n1 - } else { - 0.n1 withNight 100.n1 - } - ).value, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium - ) + } + } } - } - } - // actions - Row( - modifier = Modifier - .clip(ContinuousCapsule) - .background(100.n1 withNight 30.n1) - .padding(horizontal = 2.dp, vertical = 2.dp) - ) { + // actions + Row( + modifier = Modifier + .clip(ContinuousCapsule) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)) + .padding(horizontal = 2.dp, vertical = 2.dp) + ) { - IconButton( - modifier = Modifier.size(38.dp), - onClick = { - if (isPreviewNextSemester) { - behaviorRuntime.recordCommittedMutationAsync( - MutationId.SCHEDULE_SEMESTER_PREVIEW_CHANGED, - true, - false, - coarseValueBucket = "CURRENT_SEMESTER" + IconButton( + modifier = Modifier.size(38.dp), + onClick = { + if (isPreviewNextSemester) { + behaviorRuntime.recordCommittedMutationAsync( + MutationId.SCHEDULE_SEMESTER_PREVIEW_CHANGED, + true, + false, + coarseValueBucket = "CURRENT_SEMESTER" + ) + isPreviewNextSemester = false + } + scope.launch { + state.animateScrollToItem((currentWeek - 3).coerceAtLeast(0)) + } + scope.launch { + pagerState.animateScrollToPage((scheduleConfig?.week ?: 1) - 1) + } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_aiming), + contentDescription = "回到本周", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(17.dp) ) - isPreviewNextSemester = false } - scope.launch { - state.animateScrollToItem((currentWeek - 3).coerceAtLeast(0)) + IconButton( + modifier = Modifier.size(38.dp), + onClick = { isSettingsVisible = true } + ) { + Icon( + painter = painterResource(R.drawable.ic_config), + contentDescription = "课表设置", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(17.dp) + ) } - scope.launch { - pagerState.animateScrollToPage((scheduleConfig?.week ?: 1) - 1) + IconButton( + modifier = Modifier.size(38.dp), + onClick = { + if (isPreviewNextSemester) { + scheduleViewModel.refreshNextSchedule(true) + } else { + behaviorReporter.organic(AppActionId.MANUAL_REFRESH_SCHEDULE) + scheduleViewModel.refreshSchedule(true) + } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_refresh), + contentDescription = "刷新课表", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(17.dp) + ) } } - ) { - Icon( - imageVector = Icons.Default.MyLocation, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) + } } - IconButton( - modifier = Modifier.size(38.dp), - onClick = { isSettingsVisible = true } + // schedule 网格:可穿透滚动内容区——上穿渐变标题栏、下穿底部导航。 + // 结构:Box 铺满整页(无外层留白),标题栏渐变层 zIndex 盖在其上; + // 滚动 Column 自带顶部停靠占位(标题栏高度)与底部 nav 留白。 + Box( + modifier = Modifier + .fillMaxSize() ) { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - } - IconButton( - modifier = Modifier.size(38.dp), - onClick = { - if (isPreviewNextSemester) { - scheduleViewModel.refreshNextSchedule(true) - } else { - behaviorReporter.organic(AppActionId.MANUAL_REFRESH_SCHEDULE) - scheduleViewModel.refreshSchedule(true) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(102.dp)) + val cellWidth = ( + LocalConfiguration.current.screenWidthDp.dp - + 12.dp - // 网格卡左右各 6dp 内缩 + CourseCardSpec.mainColumnWidth - + CourseCardSpec.cellSpacing * 9 + ) / 7 + val cellHeight = 48.dp + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth() + ) { page -> + val pageWeek = page + 1 + val gridShape = SmoothRoundedCornerShape(32.dp) + Box( + modifier = with(CourseCardSpec) { + Modifier + .fillMaxWidth() + .padding(horizontal = 6.dp) + .height(mainRowHeight + (cellHeight + cellSpacing) * 13 + 24.dp) + .liquidGlassSurface( + backdrop = backdrop, + shape = gridShape, + surfaceColor = liquidGlassTint() + ) + .padding(top = 8.dp) + .padding(cellSpacing) + } + ) { + // 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) + } + } + ) + } + + // 左上空闲格:显示本页(按周一)所在月份,竖排(如 9\n月) + val monthNumber = SimpleDateFormat("M", Locale.CHINA) + .format(weekDates.first().time) + Column( + modifier = with(CourseCardSpec) { + Modifier + .size(mainColumnWidth, mainRowHeight) + .clip(SmoothRoundedCornerShape(8.dp)) + }, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = monthNumber, + color = 50.n1 withNight 80.n1, + style = MaterialTheme.typography.labelMedium + ) + Text( + text = "月", + color = 50.n1 withNight 80.n1, + style = MaterialTheme.typography.labelMedium + ) + } + + 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( + // Radiant 密度优化:日期只显示"日",不显示月 + text = SimpleDateFormat("d", Locale.CHINA).format(date.time), + color = if (isCurrentWeekday) 0.n1 else 50.n1 withNight 80.n1, + style = MaterialTheme.typography.labelSmall + ) + } + } + + // time tags + // Radiant 密度优化:节次列显示节号 + 开始时间(9sp) + 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 = TextStyle(fontSize = 9.sp) + ) + } + } + // 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 + } + ) + } + } + } 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 + } + ) + } + } + } + } } } - ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) + // 底部留白:内容可滚入底部导航栏之下 + Spacer(modifier = Modifier.height(108.dp)) + } } + if (isSettingsVisible) { + ScheduleSettingsDialog( + isOverviewSchedule = isOverviewSchedule, + isPreviewNextSemester = isPreviewNextSemester, + backdropColor = settingsCardColor, + onOverviewChange = { enabled -> + val oldValue = isOverviewSchedule + isOverviewSchedule = enabled + behaviorRuntime.recordCommittedMutationAsync( + MutationId.SCHEDULE_OVERVIEW_CHANGED, + oldValue, + enabled + ) + }, + onPreviewNextSemesterChange = { enabled -> + val oldValue = isPreviewNextSemester + isPreviewNextSemester = enabled + behaviorRuntime.recordCommittedMutationAsync( + MutationId.SCHEDULE_SEMESTER_PREVIEW_CHANGED, + oldValue, + enabled, + coarseValueBucket = if (enabled) "NEXT_SEMESTER" else "CURRENT_SEMESTER" + ) + }, + onDismiss = { isSettingsVisible = false } + ) + } + // course dialog + detailedCourse?.let { + CourseDetailDialog( + course = it, + onDismiss = { detailedCourse = null } + ) + } } } - // schedule - val cellWidth = ( - LocalConfiguration.current.screenWidthDp.dp - - CourseCardSpec.mainColumnWidth - - CourseCardSpec.cellSpacing * 9 - ) / 7 - val cellHeight = 48.dp - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxWidth() - ) { page -> - val pageWeek = page + 1 - Box( - modifier = with(CourseCardSpec) { - Modifier - .fillMaxWidth() - .height(mainRowHeight + (cellHeight + cellSpacing) * 13 + 24.dp) - .clip(SmoothRoundedCornerShape(32.dp)) - .background(99.n1 withNight 20.n1) - .padding(top = 8.dp) - .padding(cellSpacing) - } + } else { + // 冻结分支:原版课表,逐行保留,不再修改 + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .systemBarsPadding() + .padding(bottom = 96.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.padding(end = 8.dp), ) { - // 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) - } + // week selector + LazyRow( + modifier = Modifier.weight(1f), + state = state, + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(20) { + val week = it + 1 + val isSelected = week == currentWeek + CompositionLocalProvider( + LocalIndication provides ripple( + color = if (isSelected) { + 100.n1 withNight 0.n1 + } else { + 0.n1 withNight 100.n1 + } + ) + ) { + Text( + text = week.toString(), + modifier = Modifier + .clip(ContinuousCapsule) + .background( + animateColorAsState( + targetValue = if (isSelected) { + 40.a1 withNight 90.a1 + } else { + Color.Transparent + } + ).value + ) + .clickable { + if (currentWeek != week) { + behaviorRuntime.recordCommittedMutationAsync( + MutationId.SCHEDULE_WEEK_CHANGED, + currentWeek, + week, + coarseValueBucket = if (week == scheduleConfig?.week) "CURRENT_WEEK" else "OTHER_WEEK" + ) + } + scope.launch { + pagerState.animateScrollToPage(week - 1) + } + } + .padding(16.dp, 8.dp), + color = animateColorAsState( + targetValue = if (isSelected) { + 100.n1 withNight 0.n1 + } else { + 0.n1 withNight 100.n1 + } + ).value, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) } - ) + } } + // actions + Row( + modifier = Modifier + .clip(ContinuousCapsule) + .background(100.n1 withNight 30.n1) + .padding(horizontal = 2.dp, vertical = 2.dp) + ) { - 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 + IconButton( + modifier = Modifier.size(38.dp), + onClick = { + if (isPreviewNextSemester) { + behaviorRuntime.recordCommittedMutationAsync( + MutationId.SCHEDULE_SEMESTER_PREVIEW_CHANGED, + true, + false, + coarseValueBucket = "CURRENT_SEMESTER" ) - .clip(SmoothRoundedCornerShape(8.dp)) - .background(if (isCurrentWeekday) 90.a1 else Color.Unspecified) - }, - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally + isPreviewNextSemester = false + } + scope.launch { + state.animateScrollToItem((currentWeek - 3).coerceAtLeast(0)) + } + scope.launch { + pagerState.animateScrollToPage((scheduleConfig?.week ?: 1) - 1) + } + } ) { - 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 + Icon( + imageVector = Icons.Default.MyLocation, + contentDescription = null, + modifier = Modifier.size(20.dp) ) } - } - - // 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 + IconButton( + modifier = Modifier.size(38.dp), + onClick = { isSettingsVisible = true } ) { - Text( - text = index.toString(), - style = MaterialTheme.typography.labelLarge + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + modifier = Modifier.size(20.dp) ) - Text( - text = time.substringBefore("-"), - color = 50.n1 withNight 80.n1, - style = MaterialTheme.typography.labelSmall + } + IconButton( + modifier = Modifier.size(38.dp), + onClick = { + if (isPreviewNextSemester) { + scheduleViewModel.refreshNextSchedule(true) + } else { + behaviorReporter.organic(AppActionId.MANUAL_REFRESH_SCHEDULE) + scheduleViewModel.refreshSchedule(true) + } + } + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(20.dp) ) } } - // 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 - } - ) + } + // schedule + val cellWidth = ( + LocalConfiguration.current.screenWidthDp.dp - + CourseCardSpec.mainColumnWidth - + CourseCardSpec.cellSpacing * 9 + ) / 7 + val cellHeight = 48.dp + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth() + ) { page -> + val pageWeek = page + 1 + Box( + modifier = with(CourseCardSpec) { + Modifier + .fillMaxWidth() + .height(mainRowHeight + (cellHeight + cellSpacing) * 13 + 24.dp) + .clip(SmoothRoundedCornerShape(32.dp)) + .background(99.n1 withNight 20.n1) + .padding(top = 8.dp) + .padding(cellSpacing) + } + ) { + // 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 + ) } - } 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 - } - ) + } + + // 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 + ) + } + } + // 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 + } + ) + } + } + } 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 + } + ) + } } } } } } - } - if (isSettingsVisible) { - ScheduleSettingsDialog( - isOverviewSchedule = isOverviewSchedule, - isPreviewNextSemester = isPreviewNextSemester, - backdropColor = settingsCardColor, - onOverviewChange = { enabled -> - val oldValue = isOverviewSchedule - isOverviewSchedule = enabled - behaviorRuntime.recordCommittedMutationAsync( - MutationId.SCHEDULE_OVERVIEW_CHANGED, - oldValue, - enabled - ) - }, - onPreviewNextSemesterChange = { enabled -> - val oldValue = isPreviewNextSemester - isPreviewNextSemester = enabled - behaviorRuntime.recordCommittedMutationAsync( - MutationId.SCHEDULE_SEMESTER_PREVIEW_CHANGED, - oldValue, - enabled, - coarseValueBucket = if (enabled) "NEXT_SEMESTER" else "CURRENT_SEMESTER" - ) - }, - onDismiss = { isSettingsVisible = false } - ) - } - // course dialog - detailedCourse?.let { - CourseDetailDialog( - course = it, - onDismiss = { detailedCourse = null } - ) + if (isSettingsVisible) { + ScheduleSettingsDialog( + isOverviewSchedule = isOverviewSchedule, + isPreviewNextSemester = isPreviewNextSemester, + backdropColor = settingsCardColor, + onOverviewChange = { enabled -> + val oldValue = isOverviewSchedule + isOverviewSchedule = enabled + behaviorRuntime.recordCommittedMutationAsync( + MutationId.SCHEDULE_OVERVIEW_CHANGED, + oldValue, + enabled + ) + }, + onPreviewNextSemesterChange = { enabled -> + val oldValue = isPreviewNextSemester + isPreviewNextSemester = enabled + behaviorRuntime.recordCommittedMutationAsync( + MutationId.SCHEDULE_SEMESTER_PREVIEW_CHANGED, + oldValue, + enabled, + coarseValueBucket = if (enabled) "NEXT_SEMESTER" else "CURRENT_SEMESTER" + ) + }, + onDismiss = { isSettingsVisible = false } + ) + } + // course dialog + detailedCourse?.let { + CourseDetailDialog( + course = it, + onDismiss = { detailedCourse = null } + ) + } } } } @@ -669,7 +1103,8 @@ private fun OverviewCourseGroupCard( ) { OverviewCourseContent( course = item, - stackedCount = sortedCourses.size + stackedCount = sortedCourses.size, + slotHeightDp = fullHeight / sortedCourses.size ) } } @@ -689,16 +1124,94 @@ private fun OverviewCourseGroupCard( @Composable private fun BoxScope.OverviewCourseContent( course: Course, - stackedCount: Int + stackedCount: Int, + slotHeightDp: Dp ) { + if (isRadiantUi) { + RadiantOverviewCourseContent(course, stackedCount, slotHeightDp) + } else { + Text( + text = course.name ?: "", + modifier = Modifier.padding(bottom = 38.dp), + color = 100.n1, + fontWeight = FontWeight.Bold, + maxLines = if (stackedCount <= 1) 3 else 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium + ) + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + verticalArrangement = Arrangement.spacedBy(2.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = course.weekRangeText(), + color = 100.n1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + style = TextStyle( + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + ) + OverviewLocationPill( + text = course.location.shortScheduleLocation(), + maxLines = if (stackedCount <= 1) 2 else 1 + ) + } + } +} + +@Composable +private fun BoxScope.RadiantOverviewCourseContent( + course: Course, + stackedCount: Int, + slotHeightDp: Dp +) { + // Radiant:与普通课程卡统一的字号/省略策略(12sp 课程名 + 9sp 底部文字 + 动态省略) + val locationText = course.location.shortScheduleLocation() + val weekText = course.weekRangeText() + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + + val maxLines = remember(course.name, locationText, weekText, stackedCount, slotHeightDp, density) { + val pillHeight = with(density) { + textMeasurer.measure( + text = AnnotatedString(locationText), + style = TextStyle(fontSize = 9.sp, fontWeight = FontWeight.Bold) + ).size.height.toDp() + 2.dp * 2 + 2.dp * 2 + } + val weekHeight = with(density) { + textMeasurer.measure( + text = AnnotatedString(weekText), + style = TextStyle(fontSize = 9.sp, fontWeight = FontWeight.Bold) + ).size.height.toDp() + } + val courseStyle = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Bold) + val oneLineHeight = with(density) { + textMeasurer.measure( + text = AnnotatedString("测试"), + style = courseStyle + ).size.height.toDp() + } + val slotUsable = slotHeightDp - 4.dp * 2 - weekHeight - pillHeight - 2.dp - 2.dp + ((slotUsable / oneLineHeight).toInt()).coerceIn(1, 8) + } + Text( text = course.name ?: "", - modifier = Modifier.padding(bottom = 38.dp), + modifier = Modifier + .fillMaxSize() + .wrapContentHeight(Alignment.Top) + .padding(bottom = 4.dp + 38.dp), color = 100.n1, fontWeight = FontWeight.Bold, - maxLines = if (stackedCount <= 1) 3 else 2, + maxLines = maxLines, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.labelMedium + style = TextStyle(fontSize = 12.sp) ) Column( modifier = Modifier @@ -708,18 +1221,18 @@ private fun BoxScope.OverviewCourseContent( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = course.weekRangeText(), + text = weekText, color = 100.n1, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, style = TextStyle( - fontSize = 11.sp, + fontSize = 9.sp, fontWeight = FontWeight.Bold ) ) OverviewLocationPill( - text = course.location.shortScheduleLocation(), + text = locationText, maxLines = if (stackedCount <= 1) 2 else 1 ) } @@ -741,7 +1254,7 @@ private fun OverviewLocationPill( overflow = TextOverflow.Ellipsis, maxLines = maxLines, style = TextStyle( - fontSize = 11.sp, + fontSize = if (isRadiantUi) 9.sp else 11.sp, color = 10.n1 withNight 90.n1, fontWeight = FontWeight.Bold ) 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..d91f66bd 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 @@ -161,12 +161,6 @@ fun Tools( onClick = { navController.navigate(widget.route) } ) } - ToolItem( - title = "网费充值", - iconId = R.drawable.ic_network_recharge, - tint = Color(0xFF1E88E5), - onClick = { navController.navigate("network_recharge") } - ) } Column( modifier = Modifier @@ -208,7 +202,7 @@ fun Tools( } @Composable -private fun ToolItem( +internal fun ToolItem( title: String, iconId: Int, tint: Color, 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..137183de 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 @@ -26,12 +26,20 @@ 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.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import com.ahu.ahutong.data.weather.WeatherResponse +import com.ahu.ahutong.R +import com.ahu.ahutong.ui.components.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.SecondarySearchState +import com.ahu.ahutong.ui.components.TrailingAction +import com.ahu.ahutong.ui.components.GlassCard import com.ahu.ahutong.ui.state.WeatherHomeMode import com.ahu.ahutong.ui.state.WeatherViewModel import com.kyant.monet.n1 @@ -72,80 +80,33 @@ fun Weather( } } - Column( - 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, "关闭搜索") - } - val doSearch = { - if (searchCity.isNotBlank()) { - weatherViewModel.fetchWeather(searchCity) - showSearch = false - } - } - OutlinedTextField( - 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, "搜索") - } - } - } - ) - } 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, "刷新") - } - } - } + val doSearch = { + if (searchCity.isNotBlank()) { + weatherViewModel.fetchWeather(searchCity) + showSearch = false } + } + SecondaryPageScaffold( + title = weatherViewModel.locationName.ifBlank { "天气" }, + actions = listOf( + TrailingAction(ImageVector.vectorResource(R.drawable.ic_find), "搜索城市") { showSearch = true }, + TrailingAction(ImageVector.vectorResource(R.drawable.ic_config), "设置") { showSettings = true }, + TrailingAction(ImageVector.vectorResource(R.drawable.ic_refresh), "刷新") { + weatherViewModel.refresh() + Toast.makeText(context, "已刷新", Toast.LENGTH_SHORT).show() + } + ), + search = SecondarySearchState( + query = searchCity, + visible = showSearch, + onQueryChange = { searchCity = it }, + onClose = { + showSearch = false + searchCity = "" + }, + onSubmit = doSearch + ) + ) { if (weatherViewModel.isLoading) { Box( @@ -168,19 +129,6 @@ fun Weather( } else if (weather != null) { WeatherCard(weather) - weather.forecast?.let { forecast -> - Spacer(Modifier.height(16.dp)) - Text( - "未来预报", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(start = 4.dp, bottom = 8.dp) - ) - LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(forecast) { day -> ForecastCard(day) } - } - } - weather.aqi?.let { Spacer(Modifier.height(16.dp)) AqiCard(weather) @@ -204,6 +152,19 @@ fun Weather( } } + weather.forecast?.let { forecast -> + Spacer(Modifier.height(16.dp)) + Text( + "未来预报", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 4.dp, bottom = 8.dp) + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(forecast) { day -> ForecastCard(day) } + } + } + weather.lifeIndices?.let { indices -> Spacer(Modifier.height(16.dp)) Text( @@ -338,9 +299,9 @@ private fun WeatherModeChip( @Composable private fun WeatherCard(weather: WeatherResponse) { - Card( + GlassCard( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = 90.a1 withNight 30.a1) + containerColor = 90.a1 withNight 30.a1 ) { Column( modifier = Modifier.padding(20.dp), @@ -391,9 +352,10 @@ private fun InfoItem(label: String, value: String) { @Composable private fun ForecastCard(day: com.ahu.ahutong.data.weather.ForecastDay) { - Card( + GlassCard( modifier = Modifier.width(100.dp), - colors = CardDefaults.cardColors(containerColor = 100.n1 withNight 20.n1) + containerColor = 100.n1 withNight 20.n1, + glassShadow = null ) { Column( modifier = Modifier.padding(12.dp), @@ -419,9 +381,9 @@ private fun AqiCard(weather: WeatherResponse) { 6 -> androidx.compose.ui.graphics.Color(0xFF880E4F) else -> androidx.compose.ui.graphics.Color.Gray } - Card( + GlassCard( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = 100.n1 withNight 20.n1) + containerColor = 100.n1 withNight 20.n1 ) { Row( modifier = Modifier.padding(16.dp), @@ -487,9 +449,10 @@ private fun UmbrellaCard(weather: WeatherResponse) { else androidx.compose.ui.graphics.Color(0xFF4CAF50).copy(alpha = 0.15f) - Card( + GlassCard( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = bgColor) + containerColor = bgColor, + overlayColor = bgColor ) { Row( modifier = Modifier.padding(16.dp), @@ -513,9 +476,10 @@ 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 - Card( + GlassCard( modifier = Modifier.width(88.dp), - colors = CardDefaults.cardColors(containerColor = 100.n1 withNight 20.n1) + containerColor = 100.n1 withNight 20.n1, + glassShadow = null ) { Column( modifier = Modifier.padding(8.dp), @@ -552,9 +516,10 @@ private fun LifeIndicesGrid(indices: com.ahu.ahutong.data.weather.LifeIndices) { horizontalArrangement = Arrangement.spacedBy(8.dp) ) { row.forEach { (label, item) -> - Card( + GlassCard( modifier = Modifier.weight(1f), - colors = CardDefaults.cardColors(containerColor = 100.n1 withNight 20.n1) + containerColor = 100.n1 withNight 20.n1, + glassShadow = null ) { Column(modifier = Modifier.padding(12.dp)) { Text(label, fontWeight = FontWeight.Bold, fontSize = 14.sp) 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..a1630607 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 @@ -17,12 +17,14 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset 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.navigation.NavHostController import com.ahu.ahutong.data.debug.DebugClock import com.ahu.ahutong.data.model.Course +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.ScheduleViewModel import com.kyant.monet.a1 @@ -38,6 +40,107 @@ fun AtAGlance( isInSemester: Boolean = true, enabled: Boolean = true, trailingContent: @Composable RowScope.() -> Unit = {} +) { + if (isRadiantUi) { + RadiantAtAGlance(todayCourses, currentMinutes, navController, isInSemester, enabled) + } else { + ClassicAtAGlance(todayCourses, currentMinutes, navController, isInSemester, enabled, trailingContent) + } +} + +@Composable +private fun RadiantAtAGlance( + todayCourses: List, + currentMinutes: Int, + navController: NavHostController, + isInSemester: Boolean, + enabled: Boolean +) { + val currentCourse = todayCourses.find { + currentMinutes in ScheduleViewModel.getCourseTimeRangeInMinutes(it) + } + val currentCourseIndex = todayCourses.indexOfFirst { + val range = ScheduleViewModel.getCourseTimeRangeInMinutes(it) + if (currentMinutes in range) { + true + } else { + currentMinutes < range.first + } + }.takeIf { it != -1 } ?: todayCourses.lastIndex + val hasRemainingCourses = if (todayCourses.isNotEmpty()) { + currentMinutes <= ScheduleViewModel.getCourseTimeRangeInMinutes(todayCourses.last()).last + } else { + false + } + val headline = when { + currentCourse != null -> "正在上课 · ${currentCourse.name}" + hasRemainingCourses -> "下节课是 ${todayCourses[currentCourseIndex].name}" + !isInSemester -> "假期中" + else -> "今日空闲" + } + val subtitle = when { + currentCourse != null -> { + val duration = + ScheduleViewModel.getCourseTimeRangeInMinutes(currentCourse).last - currentMinutes + "距下课还有 " + when { + duration % 60 == 0 -> "${duration / 60}小时整" + duration > 60 -> "${duration / 60}小时${duration % 60}分钟" + else -> "${duration}分钟" + } + } + + hasRemainingCourses -> { + val duration = + ScheduleViewModel.getCourseTimeRangeInMinutes( + todayCourses[currentCourseIndex] + ).first - currentMinutes + "还有 " + when { + duration % 60 == 0 -> "${duration / 60}小时整" + duration > 60 -> "${duration / 60}小时${duration % 60}分钟" + else -> "${duration}分钟" + } + ",在 ${todayCourses[currentCourseIndex].location}" + } + + !isInSemester -> "准备您自己的安排吧" + else -> "今天暂无课程安排" + } + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled) { navController.navigate("schedule") } + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = headline, + fontSize = 30.sp, + fontWeight = FontWeight.Bold, + lineHeight = 34.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineLarge + ) + Text( + text = subtitle, + maxLines = 1, + overflow = TextOverflow.Clip, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Composable +private fun ClassicAtAGlance( + todayCourses: List, + currentMinutes: Int, + navController: NavHostController, + isInSemester: Boolean, + enabled: Boolean, + trailingContent: @Composable RowScope.() -> Unit ) { val currentCourse = todayCourses.find { currentMinutes in ScheduleViewModel.getCourseTimeRangeInMinutes(it) @@ -161,4 +264,4 @@ fun AtAGlance( ) } } -} +} \ No newline at end of file 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..8066e589 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 @@ -1,7 +1,13 @@ package com.ahu.ahutong.ui.screen.main.home +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -10,7 +16,6 @@ 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.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -41,6 +46,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -57,24 +63,31 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import com.ahu.ahutong.R import com.ahu.ahutong.data.dao.AHUCache import com.ahu.ahutong.data.dao.PreferencesManager +import com.ahu.ahutong.ui.components.LocalIsLiquidGlassEnabled +import com.ahu.ahutong.ui.components.isRadiantUi +import com.ahu.ahutong.ui.components.liquidGlassSurface +import com.ahu.ahutong.ui.components.liquidGlassTint import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.DiscoveryViewModel 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.kyant.backdrop.Backdrop import com.kyant.monet.n1 import com.kyant.monet.withNight import java.util.Locale @OptIn(ExperimentalAnimationApi::class) @Composable -fun RowScope.CampusCard( +fun CampusCard( balance: Double, transitionBalance: Double, onRefreshBalance: () -> Unit, navController: NavController, - enabled: Boolean = true + enabled: Boolean = true, + backdrop: Backdrop, + modifier: Modifier = Modifier ) { val context = LocalContext.current val preferencesManager = remember { PreferencesManager(context = context) } @@ -119,31 +132,54 @@ fun RowScope.CampusCard( } + val campusShape = SmoothRoundedCornerShape(24.dp) Box( - modifier = Modifier - .weight(1f) - ) { - if (isQrcode) { - QRcodeView( - balance = balance, - onBack = { - isQrcode = false + modifier = modifier + .then( + if (isRadiantUi) { + // RadiantUI:保留液态玻璃与阴影 + Modifier.liquidGlassSurface(backdrop, campusShape, liquidGlassTint()) + } else { + // ORIGINAL / LIQUID_GLASS:纯色卡片,不带玻璃与阴影 + Modifier + .clip(campusShape) + .background(100.n1 withNight 20.n1) } ) - } else { - CardView( - balance = balance, - transitionBalance = transitionBalance, - onClick = { - behaviorRuntime.recordActionIntentAsync(AppActionId.OPEN_PAYMENT_QR, ActionSource.ORGANIC) - isQrcode = true - }, - navController = navController, - enabled = enabled, - modifier = Modifier - .fillMaxWidth() - .height(140.dp) - ) + ) { + AnimatedContent( + targetState = isQrcode, + transitionSpec = { + (fadeIn(animationSpec = tween(durationMillis = 150)) togetherWith fadeOut(animationSpec = tween(durationMillis = 150))) + .using(SizeTransform(clip = true)) + }, + contentAlignment = Alignment.TopStart, + label = "campus-card-qrcode" + ) { showQrcode -> + if (showQrcode) { + QRcodeView( + balance = balance, + onBack = { + isQrcode = false + }, + backdrop = backdrop + ) + } else { + CardView( + balance = balance, + transitionBalance = transitionBalance, + onClick = { + behaviorRuntime.recordActionIntentAsync(AppActionId.OPEN_PAYMENT_QR, ActionSource.ORGANIC) + isQrcode = true + }, + navController = navController, + enabled = enabled, + backdrop = backdrop, + modifier = Modifier + .fillMaxWidth() + .height(if (isRadiantUi) 78.dp else 140.dp) + ) + } } } @@ -158,14 +194,14 @@ private fun CardView( onClick: () -> Unit, navController: NavController, enabled: Boolean, + backdrop: Backdrop, modifier: Modifier = Modifier ) { + val shape = SmoothRoundedCornerShape(24.dp) Row( - modifier = modifier - .clip(SmoothRoundedCornerShape(24.dp)) - .background(100.n1 withNight 20.n1), + modifier = modifier, verticalAlignment = Alignment.CenterVertically ) { @@ -214,24 +250,16 @@ private fun CardView( Box( modifier = Modifier .fillMaxHeight() + .then( + if (isRadiantUi) { + Modifier.width(76.dp) + } else { + Modifier.padding(16.dp) + } + ) .then( if (enabled) { Modifier.clickable { -// try { -// context.startActivity( -// Intent( -// Intent.ACTION_VIEW, -// Uri.parse( -// "alipays://platformapi/startapp?appId=2019090967125695&page=pages%2Findex%2Findex&enbsv=0.3.2106171038.6&chInfo=ch_share__chsub_CopyLink" -// ) -// ).apply { -// flags = Intent.FLAG_ACTIVITY_CLEAR_TOP -// } -// ) -// } catch (e: Exception) { -// Toast.makeText(context, "请安装支付宝", Toast.LENGTH_SHORT).show() -// } - val route = if (AHUCache.isCmbCardRechargePreferred()) { "cmb_card_recharge" } else { @@ -242,14 +270,34 @@ private fun CardView( } else { Modifier } - ) - .padding(16.dp), + ), contentAlignment = Alignment.Center ) { - Text( - text = "充\n值", - style = MaterialTheme.typography.titleMedium - ) + if (isRadiantUi) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_income), + contentDescription = "充值", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(22.dp) + ) + Spacer(Modifier.height(3.dp)) + Text( + text = "充值", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } else { + Text( + text = "充\n值", + style = MaterialTheme.typography.titleMedium + ) + } } } @@ -262,7 +310,7 @@ private fun formatCampusCardBalance(balance: Double): String { @Composable -private fun QRcodeView(balance: Double, onBack: () -> Unit) { +private fun QRcodeView(balance: Double, onBack: () -> Unit, backdrop: Backdrop) { val discoveryViewModel: DiscoveryViewModel = hiltViewModel() val qrcodeBitmap by discoveryViewModel.qrcode.collectAsState() val finished by discoveryViewModel.state.collectAsState() @@ -281,12 +329,10 @@ private fun QRcodeView(balance: Double, onBack: () -> Unit) { ).behaviorPredictionRuntime() } - DisposableEffect(activity) { - activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + DisposableEffect(Unit) { behaviorRuntime.setInlineSensitiveUiVisible(true) onDispose { behaviorRuntime.setInlineSensitiveUiVisible(false) - activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } } @@ -334,10 +380,10 @@ private fun QRcodeView(balance: Double, onBack: () -> Unit) { } } + val shape = SmoothRoundedCornerShape(24.dp) Column( modifier = Modifier - .clip(SmoothRoundedCornerShape(24.dp)) - .background(100.n1 withNight 20.n1) + .fillMaxWidth() .padding( start = 20.dp, top = 12.dp, @@ -417,6 +463,7 @@ private fun QRcodeView(balance: Double, onBack: () -> Unit) { text = "¥ ${formatCampusCardBalance(balance)}", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, + color = if (LocalIsLiquidGlassEnabled.current) MaterialTheme.colorScheme.onSurface else Color.Unspecified, modifier = Modifier.padding(top = 12.dp) ) diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeDateRow.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeDateRow.kt new file mode 100644 index 00000000..c18f6bb3 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeDateRow.kt @@ -0,0 +1,39 @@ +package com.ahu.ahutong.ui.screen.main.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.ahu.ahutong.data.debug.DebugClock +import com.kyant.monet.n1 +import com.kyant.monet.withNight +import java.text.SimpleDateFormat +import java.util.Locale + +@Composable +fun HomeDateRow( + trailingContent: @Composable RowScope.() -> Unit = {} +) { + val date = SimpleDateFormat("MM-dd / EE", Locale.CHINA).format(DebugClock.nowDate()) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 16.dp, top = 4.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = date, + style = MaterialTheme.typography.bodyMedium, + color = 45.n1 withNight 75.n1 + ) + trailingContent() + } +} \ No newline at end of file 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..b6c00d1c 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 @@ -12,7 +12,9 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress @@ -73,7 +75,13 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.navigation.NavHostController +// 更多入口图标(复用 HomeWidgetRegistry 的着色,颜色取偏好设置里的应用主题色 MaterialTheme.colorScheme.primary) +import com.ahu.ahutong.R +import com.ahu.ahutong.ui.components.isRadiantUi +import com.ahu.ahutong.ui.components.liquidGlassSurface +import com.ahu.ahutong.ui.components.liquidGlassTint import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.kyant.backdrop.Backdrop import com.kyant.monet.a1 import com.kyant.monet.n1 import com.kyant.monet.withNight @@ -95,7 +103,155 @@ fun HomeWidgetSlotLayout( onSlotPositioned: (slotIndex: Int, bounds: Rect) -> Unit, onHomeWidgetDragStarted: (widgetId: String, slotIndex: Int, bounds: Rect) -> Unit, onHomeWidgetDragged: (Offset) -> Unit, - onHomeWidgetDragStopped: () -> Unit + onHomeWidgetDragStopped: () -> Unit, + backdrop: Backdrop +) { + if (isRadiantUi) { + RadiantHomeWidgetSlotLayout( + balance = balance, + transitionBalance = transitionBalance, + onRefreshBalance = onRefreshBalance, + navController = navController, + slots = slots, + isEditing = isEditing, + highlightedSlot = highlightedSlot, + draggingWidgetId = draggingWidgetId, + onEnterEdit = onEnterEdit, + onHomeWidgetClick = onHomeWidgetClick, + onSlotPositioned = onSlotPositioned, + onHomeWidgetDragStarted = onHomeWidgetDragStarted, + onHomeWidgetDragged = onHomeWidgetDragged, + onHomeWidgetDragStopped = onHomeWidgetDragStopped, + backdrop = backdrop + ) + } else { + ClassicHomeWidgetSlotLayout( + balance = balance, + transitionBalance = transitionBalance, + onRefreshBalance = onRefreshBalance, + navController = navController, + slots = slots, + isEditing = isEditing, + highlightedSlot = highlightedSlot, + draggingWidgetId = draggingWidgetId, + onEnterEdit = onEnterEdit, + onHomeWidgetClick = onHomeWidgetClick, + onSlotPositioned = onSlotPositioned, + onHomeWidgetDragStarted = onHomeWidgetDragStarted, + onHomeWidgetDragged = onHomeWidgetDragged, + onHomeWidgetDragStopped = onHomeWidgetDragStopped, + backdrop = backdrop + ) + } +} + +@Composable +private fun RadiantHomeWidgetSlotLayout( + balance: Double, + transitionBalance: Double, + onRefreshBalance: () -> Unit, + navController: NavHostController, + slots: List, + isEditing: Boolean, + highlightedSlot: Int?, + draggingWidgetId: String?, + onEnterEdit: () -> Unit, + onHomeWidgetClick: (slotIndex: Int) -> Unit, + onSlotPositioned: (slotIndex: Int, bounds: Rect) -> Unit, + onHomeWidgetDragStarted: (widgetId: String, slotIndex: Int, bounds: Rect) -> Unit, + onHomeWidgetDragged: (Offset) -> Unit, + onHomeWidgetDragStopped: () -> Unit, + backdrop: Backdrop +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CampusCard( + balance = balance, + transitionBalance = transitionBalance, + onRefreshBalance = onRefreshBalance, + navController = navController, + enabled = !isEditing, + backdrop = backdrop, + modifier = Modifier.fillMaxWidth() + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + listOf(listOf(1, 2, 3, 4), listOf(5, 6, 7)).forEach { rowSlots -> + val isLastRow = rowSlots.last() == 7 + val visibleSlots = if (isEditing) { + rowSlots + } else { + rowSlots.filter { slots.getOrNull(it - 1) != null } + } + val rowHasWidgets = visibleSlots.isNotEmpty() + if (isEditing || rowHasWidgets || isLastRow) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + visibleSlots.forEach { slotIndex -> + val widgetId = slots.getOrNull(slotIndex - 1) + HomeWidgetSlot( + slotIndex = slotIndex, + widgetId = widgetId, + isEditing = isEditing, + isHighlighted = highlightedSlot == slotIndex, + isDragging = draggingWidgetId == widgetId, + modifier = Modifier + .weight(1f) + .height(64.dp), + onEnterEdit = onEnterEdit, + onNavigate = { navController.navigate(it) }, + onClick = onHomeWidgetClick, + onSlotPositioned = onSlotPositioned, + onDragStarted = onHomeWidgetDragStarted, + onDragged = onHomeWidgetDragged, + onDragStopped = onHomeWidgetDragStopped, + backdrop = backdrop + ) + } + if (isLastRow) { + val moreModifier = if (rowHasWidgets) { + Modifier.weight(1f) + } else { + Modifier.width(68.dp) + } + HomeWidgetMoreItem( + onClick = { navController.navigate("widgets") }, + modifier = moreModifier.height(64.dp) + ) + } + } + } + } + } + } +} + +@Composable +private fun ClassicHomeWidgetSlotLayout( + balance: Double, + transitionBalance: Double, + onRefreshBalance: () -> Unit, + navController: NavHostController, + slots: List, + isEditing: Boolean, + highlightedSlot: Int?, + draggingWidgetId: String?, + onEnterEdit: () -> Unit, + onHomeWidgetClick: (slotIndex: Int) -> Unit, + onSlotPositioned: (slotIndex: Int, bounds: Rect) -> Unit, + onHomeWidgetDragStarted: (widgetId: String, slotIndex: Int, bounds: Rect) -> Unit, + onHomeWidgetDragged: (Offset) -> Unit, + onHomeWidgetDragStopped: () -> Unit, + backdrop: Backdrop ) { Column( modifier = Modifier @@ -114,7 +270,9 @@ fun HomeWidgetSlotLayout( transitionBalance = transitionBalance, onRefreshBalance = onRefreshBalance, navController = navController, - enabled = !isEditing + enabled = !isEditing, + backdrop = backdrop, + modifier = Modifier.weight(1f) ) val showTopColumn = isEditing || slots.getOrNull(0) != null || slots.getOrNull(1) != null @@ -138,7 +296,8 @@ fun HomeWidgetSlotLayout( onSlotPositioned = onSlotPositioned, onDragStarted = onHomeWidgetDragStarted, onDragged = onHomeWidgetDragged, - onDragStopped = onHomeWidgetDragStopped + onDragStopped = onHomeWidgetDragStopped, + backdrop = backdrop ) HomeWidgetSlot( slotIndex = 2, @@ -155,7 +314,8 @@ fun HomeWidgetSlotLayout( onSlotPositioned = onSlotPositioned, onDragStarted = onHomeWidgetDragStarted, onDragged = onHomeWidgetDragged, - onDragStopped = onHomeWidgetDragStopped + onDragStopped = onHomeWidgetDragStopped, + backdrop = backdrop ) } } @@ -183,7 +343,8 @@ fun HomeWidgetSlotLayout( onSlotPositioned = onSlotPositioned, onDragStarted = onHomeWidgetDragStarted, onDragged = onHomeWidgetDragged, - onDragStopped = onHomeWidgetDragStopped + onDragStopped = onHomeWidgetDragStopped, + backdrop = backdrop ) HomeWidgetSlot( slotIndex = rightSlot, @@ -200,7 +361,8 @@ fun HomeWidgetSlotLayout( onSlotPositioned = onSlotPositioned, onDragStarted = onHomeWidgetDragStarted, onDragged = onHomeWidgetDragged, - onDragStopped = onHomeWidgetDragStopped + onDragStopped = onHomeWidgetDragStopped, + backdrop = backdrop ) } } @@ -223,7 +385,8 @@ private fun HomeWidgetSlot( onSlotPositioned: (slotIndex: Int, bounds: Rect) -> Unit, onDragStarted: (widgetId: String, slotIndex: Int, bounds: Rect) -> Unit, onDragged: (Offset) -> Unit, - onDragStopped: () -> Unit + onDragStopped: () -> Unit, + backdrop: Backdrop ) { val spec = widgetId?.let { HomeWidgetRegistry.widgetById[it] } var bounds by remember { mutableStateOf(null) } @@ -289,21 +452,92 @@ private fun HomeWidgetSlot( TextHomeWidgetCard( title = spec.title, + iconId = spec.iconId, + tint = spec.tint, isEditing = isEditing, isHighlighted = isHighlighted, modifier = slotModifier .alpha(if (isDragging) 0.35f else 1f), - interactionModifier = dragModifier + interactionModifier = dragModifier, ) } @Composable private fun TextHomeWidgetCard( + title: String, + iconId: Int, + tint: Color, + isEditing: Boolean, + isHighlighted: Boolean, + modifier: Modifier = Modifier, + interactionModifier: Modifier = Modifier, +) { + if (isRadiantUi) { + RadiantTextHomeWidgetCard(title, iconId, tint, isEditing, isHighlighted, modifier, interactionModifier) + } else { + ClassicTextHomeWidgetCard(title, isEditing, isHighlighted, modifier, interactionModifier) + } +} + +@Composable +private fun RadiantTextHomeWidgetCard( + title: String, + iconId: Int, + tint: Color, + isEditing: Boolean, + isHighlighted: Boolean, + modifier: Modifier = Modifier, + interactionModifier: Modifier = Modifier, +) { + val shape = SmoothRoundedCornerShape(18.dp) + Box( + modifier = modifier + .editModeMotion(isEditing) + .then( + if (isEditing || isHighlighted) { + Modifier.border( + 1.5.dp, + if (isHighlighted) 75.a1 withNight 80.a1 else 60.n1 withNight 50.n1, + shape + ) + } else { + Modifier + } + ) + .then(interactionModifier) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(id = iconId), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = tint + ) + Spacer(modifier = Modifier.padding(top = 6.dp)) + Text( + text = title, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium + ) + } + } +} + +@Composable +private fun ClassicTextHomeWidgetCard( title: String, isEditing: Boolean, isHighlighted: Boolean, modifier: Modifier = Modifier, - interactionModifier: Modifier = Modifier + interactionModifier: Modifier = Modifier, ) { val shape = SmoothRoundedCornerShape(24.dp) Box( @@ -331,6 +565,40 @@ private fun TextHomeWidgetCard( } } +@Composable +private fun HomeWidgetMoreItem( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .clickable(onClick = onClick) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_more_all), + contentDescription = "更多", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.padding(top = 6.dp)) + Text( + text = "更多", + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium + ) + } + } +} + @Composable private fun EmptyHomeWidgetSlot( isHighlighted: Boolean, @@ -573,7 +841,8 @@ fun HomeWidgetDragOverlay( spec: HomeWidgetSpec, topLeft: Offset, size: IntSize, - rootTopLeft: Offset + rootTopLeft: Offset, + backdrop: Backdrop ) { val density = LocalDensity.current Box( @@ -597,9 +866,11 @@ fun HomeWidgetDragOverlay( ) { TextHomeWidgetCard( title = spec.title, + iconId = spec.iconId, + tint = spec.tint, isEditing = false, isHighlighted = false, - modifier = Modifier.matchParentSize() + modifier = Modifier.matchParentSize(), ) } } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeWidgetRegistry.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeWidgetRegistry.kt index 7a6c6a69..ee5174bf 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeWidgetRegistry.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/home/HomeWidgetRegistry.kt @@ -12,7 +12,17 @@ data class HomeWidgetSpec( ) object HomeWidgetRegistry { - const val slotCount = 8 + /** 经典版(Original/Liquid Glass)主页插槽数量:双列布局(校园卡旁 2 个 + 三行各 2 个)。 */ + const val slotCountClassic = 8 + + /** 曜光版(RadiantUI)主页插槽数量:图标网格(4 + 3,末位为「更多」入口)。 */ + const val slotCountRadiant = 7 + + /** RadiantUI 首次启动的默认插槽(7 个填满,按展示顺序)。 */ + val defaultSlotsRadiant: List = listOf( + "electricity", "bathroom", "grade", "exam", + "weather", "network_recharge", "free_classroom" + ) val widgets = listOf( HomeWidgetSpec( @@ -98,8 +108,22 @@ object HomeWidgetRegistry { route = "xuexiaotong", iconId = R.drawable.ic_xuexiaotong, tint = Color(0xFF7C4DFF) + ), + HomeWidgetSpec( + id = "network_recharge", + title = "网费充值", + route = "network_recharge", + iconId = R.drawable.ic_network_recharge, + tint = Color(0xFF1E88E5) ) ) val widgetById = widgets.associateBy { it.id } -} + + /** + * 当前风格下可展示的小工具列表。 + * 曜光版下「学习通日历」已提级为底部 tab,从小工具列表 / 主页插槽中隐藏。 + */ + fun availableWidgets(radiant: Boolean): List = + if (radiant) widgets.filter { it.id != "xuexiaotong" } else widgets +} \ No newline at end of file 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..6b19f647 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,19 +11,25 @@ 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.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle 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.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlin.math.roundToInt import com.ahu.ahutong.data.model.Course +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.kyant.monet.LocalTonalPalettes import com.kyant.monet.PaletteStyle @@ -47,6 +53,39 @@ fun CourseCard( style = PaletteStyle.Vibrant, tonalValues = doubleArrayOf() // 此行代码解决了卡顿问题 ) ) { + // Radiant:课程名省略行数按「卡高 - 地点胶囊实际高度 - 自身边距」实时推算 + val nameMaxLines = if (isRadiantUi) { + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + val capsuleText = + if (isCurrentWeek) course.location.shortScheduleLocation() else "非本周" + remember(course.name, course.length, capsuleText, cellWidth, cellHeight) { + val capsuleStyle = TextStyle(fontSize = 9.sp, fontWeight = FontWeight.Bold) + val nameStyle = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Bold) + val capsuleTextLayout = textMeasurer.measure( + capsuleText, + capsuleStyle, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + constraints = Constraints( + maxWidth = with(density) { (cellWidth - 12.dp).roundToPx() } + ) + ) + val nameLineHeight = textMeasurer.measure("口", nameStyle) + .size.height.coerceAtLeast(1) + val cardHeightPx = with(density) { + (cellHeight * course.length + + CourseCardSpec.cellSpacing * (course.length - 1)).roundToPx() + } + val capsuleTotalPx = capsuleTextLayout.size.height + + with(density) { 12.dp.roundToPx() } + val nameAvailablePx = cardHeightPx - capsuleTotalPx - + with(density) { 8.dp.roundToPx() } + (nameAvailablePx / nameLineHeight).coerceIn(1, 8) + } + } else { + 3 + } Box( modifier = with(CourseCardSpec) { Modifier @@ -69,8 +108,12 @@ fun CourseCard( color = 100.n1, fontWeight = FontWeight.Bold, overflow = TextOverflow.Ellipsis, - maxLines = 3, - style = MaterialTheme.typography.labelMedium + maxLines = nameMaxLines, + style = if (isRadiantUi) { + TextStyle(fontSize = 12.sp) + } else { + MaterialTheme.typography.labelMedium + } ) @@ -98,7 +141,9 @@ fun CourseCard( overflow = TextOverflow.Ellipsis, maxLines = 2, style = TextStyle( - fontSize = 11.sp, color = 10.n1 withNight 90.n1, fontWeight = FontWeight.Bold + fontSize = if (isRadiantUi) 9.sp else 11.sp, + color = 10.n1 withNight 90.n1, + fontWeight = FontWeight.Bold ) ) } 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..02c16f4d 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 @@ -47,6 +47,7 @@ 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.UiStyle import com.ahu.ahutong.notification.CourseReminderCapability import com.ahu.ahutong.notification.CourseReminderNotifier import com.ahu.ahutong.notification.CourseReminderScheduler @@ -57,6 +58,7 @@ 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.SettingsSection +import com.ahu.ahutong.ui.components.SettingsSelectRow import com.ahu.ahutong.ui.components.SettingsToggleRow import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.PreferencesViewModel @@ -79,11 +81,11 @@ fun Preferences(onBack: () -> Unit = {}) { val appThemeMode by viewModel.appThemeMode.collectAsState() val showQRCode by viewModel.showQRCode.collectAsState() val useCmbCardRecharge by viewModel.useCmbCardRecharge.collectAsState() + val uiStyle by viewModel.uiStyle.collectAsState() val personalizationEnabled by viewModel.personalizationEnabled.collectAsState() val predictivePrefetchEnabled by viewModel.predictivePrefetchEnabled.collectAsState() val wifiOnlyPrefetch by viewModel.wifiOnlyPrefetch.collectAsState() val behaviorRetentionDays by viewModel.behaviorRetentionDays.collectAsState() - val useLiquidGlass by viewModel.useLiquidGlass.collectAsState() val themeColor by viewModel.themeColor.collectAsState() val courseReminderEnabled by viewModel.courseReminderEnabled.collectAsState() val courseReminderLiveCountdownEnabled by @@ -321,13 +323,16 @@ fun Preferences(onBack: () -> Unit = {}) { ), onSelected = viewModel::setAppThemeMode ) - SettingsToggleRow( - title = "液态玻璃", - subtitle = "使用 Apple 风格的玻璃控件和浮动导航", - selected = useLiquidGlass, - onSelectedChange = viewModel::setUseLiquidGlass, - backdrop = backdrop, - onHorizontalDragActiveChange = onToggleHorizontalDragActiveChange + SettingsSelectRow( + title = "UI 设置", + subtitle = "改变整套界面的组件和交互风格", + selected = uiStyle, + choices = listOf( + SettingsChoice(UiStyle.ORIGINAL, "Original"), + SettingsChoice(UiStyle.LIQUID_GLASS, "Liquid Glass"), + SettingsChoice(UiStyle.RADIANT_UI, "RadiantUI") + ), + onSelected = viewModel::setUiStyle ) ThemeColorPicker( selectedColor = themeColor, diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongDockState.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongDockState.kt new file mode 100644 index 00000000..3728f8bd --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongDockState.kt @@ -0,0 +1,18 @@ +package com.ahu.ahutong.ui.screen.xuexiaotong + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +enum class XuexiaotongSubTab { SCHEDULE, COURSE } + +// 学习通日历的两个子页(日程/课程)由底部导航栏轮换切换, +// 该状态被 XuexiaotongScreen 与 BottomNavBar 共享,保证重新进入后停留在上次子页。 +object XuexiaotongDockState { + var tab by mutableStateOf(XuexiaotongSubTab.SCHEDULE) + + fun toggle() { + tab = if (tab == XuexiaotongSubTab.SCHEDULE) XuexiaotongSubTab.COURSE + else XuexiaotongSubTab.SCHEDULE + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongScreen.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongScreen.kt index 36008593..7ffa445e 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongScreen.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures 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.PaddingValues import androidx.compose.foundation.layout.Row @@ -23,6 +24,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -34,6 +36,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -63,30 +66,38 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource 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.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.ahu.ahutong.R import com.ahu.ahutong.data.xuexiaotong.ChaoxingApi import com.ahu.ahutong.data.xuexiaotong.CourseProgress import com.ahu.ahutong.data.xuexiaotong.CustomEvent import com.ahu.ahutong.data.xuexiaotong.Work +import com.ahu.ahutong.ui.components.GlassBackdropContainer +import com.ahu.ahutong.ui.components.LocalIsLiquidGlassEnabled +import com.ahu.ahutong.ui.components.isRadiantUi +import com.ahu.ahutong.ui.components.liquidGlassSurface +import com.ahu.ahutong.ui.components.liquidGlassTint import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.kyant.backdrop.Backdrop import com.kyant.capsule.ContinuousCapsule import com.kyant.monet.n1 import com.kyant.monet.withNight +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.zIndex +import com.kyant.monet.a1 import kotlin.math.roundToInt import java.util.Calendar -private enum class Tab { SCHEDULE, COURSE } - @OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @Composable fun XuexiaotongScreen( - api: ChaoxingApi, - onBack: () -> Unit + api: ChaoxingApi ) { val viewModel = androidx.lifecycle.viewmodel.compose.viewModel( factory = XuexiaotongViewModel.Factory(api, androidx.compose.ui.platform.LocalContext.current) @@ -106,15 +117,29 @@ fun XuexiaotongScreen( val showEmptyCourses by viewModel.showEmptyCourses.collectAsState() val remindSetting by viewModel.remindSetting.collectAsState() - if (!loggedIn) { + // 未登录也可直接使用日历(自建日程本地可用),仅登录/同步需进独立登录页 + var showLogin by remember { mutableStateOf(false) } + + if (showLogin) { XuexiaotongLoginScreen( api = api, - onLoginSuccess = { viewModel.onLoginSuccess() } + onLoginSuccess = { + viewModel.onLoginSuccess() + showLogin = false + } ) return } - var tab by remember { mutableStateOf(Tab.SCHEDULE) } + // 曜光模式:子页由底部导航栏轮换(全局状态,重进停留在上次子页); + // 经典模式:子页由页面内部底部 Dock 切换(局部状态)。 + val isRadiant = isRadiantUi + var localTab by remember { mutableStateOf(XuexiaotongSubTab.SCHEDULE) } + val tab = if (isRadiant) XuexiaotongDockState.tab else localTab + fun setTab(t: XuexiaotongSubTab) { + if (isRadiant) XuexiaotongDockState.tab = t else localTab = t + } + var sideMenuOpen by remember { mutableStateOf(false) } var selectedWork by remember { mutableStateOf(null) } var showAddEvent by remember { mutableStateOf(false) } @@ -125,162 +150,366 @@ fun XuexiaotongScreen( var year by remember { mutableIntStateOf(today.get(Calendar.YEAR)) } var month by remember { mutableIntStateOf(today.get(Calendar.MONTH) + 1) } - Box(Modifier.fillMaxSize().systemBarsPadding()) { - Column(Modifier.fillMaxSize()) { - // 标题栏:标题+副标题紧凑排列,右侧按钮 - Row( + if (isRadiant) { + // Radiant:课表页同款结构——页面级 GlassBackdropContainer(一个背景层服务全部玻璃卡)+ + // 兄弟叠加:渐变标题栏 zIndex 盖在可穿透内容之上,内容上穿标题栏、下穿导航栏。 + GlassBackdropContainer(modifier = Modifier.fillMaxSize()) { pageBackdrop -> + Box( modifier = Modifier - .fillMaxWidth() - .padding(start = 24.dp, top = 12.dp, end = 24.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + .fillMaxSize() + .background(96.n1 withNight 10.n1) ) { - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Column { - val mainTitle = if (tab == Tab.COURSE) "课程任务" else "${year}年${month}月" - Text( - mainTitle, - fontSize = 22.sp, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + // 固定标题栏(渐变遮罩层) + val headerBg = if (LocalIsLiquidGlassEnabled.current) { + MaterialTheme.colorScheme.surfaceContainerLowest + } else { + 96.n1 withNight 10.n1 + } + Column( + modifier = Modifier + .fillMaxWidth() + .background( + Brush.verticalGradient( + colorStops = arrayOf( + 0f to headerBg, + 0.35f to headerBg, + 0.68f to headerBg.copy(alpha = 0.85f), + 1f to headerBg.copy(alpha = 0f) + ) + ) ) - // 副标题:同步时显示进度,否则显示上次同步时间 - val isSyncing = if (tab == Tab.COURSE) courseSyncing else syncing - val msg = if (tab == Tab.COURSE) courseSyncMsg else syncMsg - val subtitleText = if (isSyncing && msg.message.isNotEmpty()) { - msg.message - } else if (lastSync > 0) { - val c = Calendar.getInstance().apply { timeInMillis = lastSync } - val mm = (c.get(Calendar.MONTH) + 1).toString().padStart(2, '0') - val dd = c.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0') - val hh = c.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0') - val mi = c.get(Calendar.MINUTE).toString().padStart(2, '0') - "上次同步 $mm-$dd $hh:$mi" - } else "" - if (subtitleText.isNotEmpty()) { + .statusBarsPadding() + .zIndex(20f) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // 左槽:标题 + 副标题 + Column(modifier = Modifier.weight(1f)) { + val mainTitle = if (tab == XuexiaotongSubTab.COURSE) "课程任务" else "${year}年${month}月" Text( - subtitleText, - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurfaceVariant + mainTitle, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface ) + val isSyncing = if (tab == XuexiaotongSubTab.COURSE) courseSyncing else syncing + val msg = if (tab == XuexiaotongSubTab.COURSE) courseSyncMsg else syncMsg + val subtitleText = if (isSyncing && msg.message.isNotEmpty()) { + msg.message + } else if (lastSync > 0) { + val c = Calendar.getInstance().apply { timeInMillis = lastSync } + val mm = (c.get(Calendar.MONTH) + 1).toString().padStart(2, '0') + val dd = c.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0') + val hh = c.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0') + val mi = c.get(Calendar.MINUTE).toString().padStart(2, '0') + "上次同步 $mm-$dd $hh:$mi" + } else "" + if (subtitleText.isNotEmpty()) { + Text( + subtitleText, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + // 操作按钮胶囊(对齐课表:38dp 按钮 + 17dp 图标 + 2dp 内边距) + Row( + modifier = Modifier + .clip(ContinuousCapsule) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)) + .padding(horizontal = 2.dp, vertical = 2.dp) + ) { + if (tab == XuexiaotongSubTab.SCHEDULE) { + IconButton( + modifier = Modifier.size(38.dp), + onClick = { showAddEvent = true } + ) { + Icon( + painter = painterResource(R.drawable.ic_add), + contentDescription = "新建日程", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(17.dp) + ) + } + } + IconButton( + modifier = Modifier.size(38.dp), + onClick = { sideMenuOpen = true } + ) { + Icon( + painter = painterResource(R.drawable.ic_config), + contentDescription = "菜单", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(17.dp) + ) + } + val isSyncingBtn = if (tab == XuexiaotongSubTab.COURSE) courseSyncing else syncing + if (isSyncingBtn) { + Box( + modifier = Modifier.size(38.dp), + contentAlignment = Alignment.Center + ) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(17.dp), + strokeWidth = 2.dp + ) + } + } else if (!loggedIn) { + IconButton( + modifier = Modifier.size(38.dp), + onClick = { showLogin = true } + ) { + Icon( + painter = painterResource(R.drawable.ic_permission), + contentDescription = "登录", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(17.dp) + ) + } + } else { + IconButton( + modifier = Modifier.size(38.dp), + onClick = { + if (tab == XuexiaotongSubTab.COURSE) viewModel.syncCourseProgress() + else viewModel.syncWorks() + } + ) { + Icon( + painter = painterResource(R.drawable.ic_refresh), + contentDescription = "同步", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(17.dp) + ) + } + } } } } - // 操作按钮 + // 标签页内容:可穿透滚动(上穿渐变标题栏、下穿底部导航) + AnimatedContent( + targetState = tab, + transitionSpec = { + if (targetState.ordinal > initialState.ordinal) { + (slideInHorizontally { it } + fadeIn(tween(220))) togetherWith + (slideOutHorizontally { -it / 2 } + fadeOut(tween(180))) + } else { + (slideInHorizontally { -it } + fadeIn(tween(220))) togetherWith + (slideOutHorizontally { it / 2 } + fadeOut(tween(180))) + } + }, + label = "tabContent" + ) { t -> + Box(Modifier.fillMaxSize()) { + when (t) { + XuexiaotongSubTab.SCHEDULE -> { + ScheduleTab( + works = works, + customEvents = customEvents, + syncing = syncing, + syncMsg = syncMsg, + showDone = showDone, + doneGray = doneGray, + year = year, + month = month, + onChangeMonth = { delta -> + var m = month + delta + var y = year + if (m < 1) { m = 12; y-- } + if (m > 12) { m = 1; y++ } + year = y; month = m + }, + onWorkClick = { selectedWork = it }, + isRadiant = isRadiant, + pageBackdrop = pageBackdrop, + loggedIn = loggedIn + ) + } + XuexiaotongSubTab.COURSE -> { + CourseTab( + progress = progress, + syncing = courseSyncing, + syncMsg = courseSyncMsg, + showEmptyCourses = showEmptyCourses, + loggedIn = loggedIn, + isRadiant = isRadiant, + pageBackdrop = pageBackdrop + ) + } + } + } + } + } + } + } else { + Box(Modifier.fillMaxSize().systemBarsPadding()) { + Column( + Modifier.fillMaxSize() + ) { + // 标题栏:标题+副标题紧凑排列,右侧按钮 Row( modifier = Modifier - .clip(ContinuousCapsule) - .background( - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f) - ) + .fillMaxWidth() + .padding(start = 24.dp, top = 12.dp, end = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - if (tab == Tab.SCHEDULE) { - IconButton(onClick = { showAddEvent = true }) { - Icon( - Icons.Filled.Add, - contentDescription = "新建日程", - tint = MaterialTheme.colorScheme.onSurface + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column { + val mainTitle = if (tab == XuexiaotongSubTab.COURSE) "课程任务" else "${year}年${month}月" + Text( + mainTitle, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface ) + // 副标题:同步时显示进度,否则显示上次同步时间 + val isSyncing = if (tab == XuexiaotongSubTab.COURSE) courseSyncing else syncing + val msg = if (tab == XuexiaotongSubTab.COURSE) courseSyncMsg else syncMsg + val subtitleText = if (isSyncing && msg.message.isNotEmpty()) { + msg.message + } else if (lastSync > 0) { + val c = Calendar.getInstance().apply { timeInMillis = lastSync } + val mm = (c.get(Calendar.MONTH) + 1).toString().padStart(2, '0') + val dd = c.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0') + val hh = c.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0') + val mi = c.get(Calendar.MINUTE).toString().padStart(2, '0') + "上次同步 $mm-$dd $hh:$mi" + } else "" + if (subtitleText.isNotEmpty()) { + Text( + subtitleText, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } - IconButton(onClick = { sideMenuOpen = true }) { - Icon( - Icons.Filled.Menu, - contentDescription = "菜单", - tint = MaterialTheme.colorScheme.onSurface - ) - } - val isSyncingBtn = if (tab == Tab.COURSE) courseSyncing else syncing - if (isSyncingBtn) { - Box( - modifier = Modifier.size(48.dp), - contentAlignment = Alignment.Center - ) { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp + // 操作按钮 + Row( + modifier = Modifier + .clip(ContinuousCapsule) + .background( + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f) ) + ) { + if (tab == XuexiaotongSubTab.SCHEDULE) { + IconButton(onClick = { showAddEvent = true }) { + Icon( + Icons.Filled.Add, + contentDescription = "新建日程", + tint = MaterialTheme.colorScheme.onSurface + ) + } } - } else { - IconButton(onClick = { - if (tab == Tab.COURSE) viewModel.syncCourseProgress() - else viewModel.syncWorks() - }) { + IconButton(onClick = { sideMenuOpen = true }) { Icon( - Icons.Filled.Refresh, - contentDescription = "同步", + Icons.Filled.Menu, + contentDescription = "菜单", tint = MaterialTheme.colorScheme.onSurface ) } + val isSyncingBtn = if (tab == XuexiaotongSubTab.COURSE) courseSyncing else syncing + if (isSyncingBtn) { + Box( + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center + ) { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } + } else if (!loggedIn) { + // 未登录:右上角按钮切换为「登录」(占位图标,待替换正式图) + IconButton(onClick = { showLogin = true }) { + Icon( + Icons.Filled.Person, + contentDescription = "登录", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } else { + IconButton(onClick = { + if (tab == XuexiaotongSubTab.COURSE) viewModel.syncCourseProgress() + else viewModel.syncWorks() + }) { + Icon( + Icons.Filled.Refresh, + contentDescription = "同步", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } } } - } - // 标签页内容 - AnimatedContent( - targetState = tab, - transitionSpec = { - if (targetState.ordinal > initialState.ordinal) { - (slideInHorizontally { it } + fadeIn(tween(220))) togetherWith - (slideOutHorizontally { -it / 2 } + fadeOut(tween(180))) - } else { - (slideInHorizontally { -it } + fadeIn(tween(220))) togetherWith - (slideOutHorizontally { it / 2 } + fadeOut(tween(180))) - } - }, - label = "tabContent" - ) { t -> - Box(Modifier.weight(1f)) { - when (t) { - Tab.SCHEDULE -> { - ScheduleTab( - works = works, - customEvents = customEvents, - syncing = syncing, - syncMsg = syncMsg, - showDone = showDone, - doneGray = doneGray, - year = year, - month = month, - onChangeMonth = { delta -> - var m = month + delta - var y = year - if (m < 1) { m = 12; y-- } - if (m > 12) { m = 1; y++ } - year = y; month = m - }, - onWorkClick = { selectedWork = it } - ) + // 标签页内容 + AnimatedContent( + targetState = tab, + transitionSpec = { + if (targetState.ordinal > initialState.ordinal) { + (slideInHorizontally { it } + fadeIn(tween(220))) togetherWith + (slideOutHorizontally { -it / 2 } + fadeOut(tween(180))) + } else { + (slideInHorizontally { -it } + fadeIn(tween(220))) togetherWith + (slideOutHorizontally { it / 2 } + fadeOut(tween(180))) } - Tab.COURSE -> { - CourseTab( - progress = progress, - syncing = courseSyncing, - syncMsg = courseSyncMsg, - showEmptyCourses = showEmptyCourses - ) + }, + label = "tabContent" + ) { t -> + Box(Modifier.weight(1f)) { + when (t) { + XuexiaotongSubTab.SCHEDULE -> { + ScheduleTab( + works = works, + customEvents = customEvents, + syncing = syncing, + syncMsg = syncMsg, + showDone = showDone, + doneGray = doneGray, + year = year, + month = month, + onChangeMonth = { delta -> + var m = month + delta + var y = year + if (m < 1) { m = 12; y-- } + if (m > 12) { m = 1; y++ } + year = y; month = m + }, + onWorkClick = { selectedWork = it }, + isRadiant = false + ) + } + XuexiaotongSubTab.COURSE -> { + CourseTab( + progress = progress, + syncing = courseSyncing, + syncMsg = courseSyncMsg, + showEmptyCourses = showEmptyCourses, + loggedIn = loggedIn, + isRadiant = false + ) + } } } } } - } - // 底部悬浮 Dock - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .navigationBarsPadding() - .padding(bottom = 8.dp), - contentAlignment = Alignment.Center - ) { - BottomDock(current = tab, onSelect = { tab = it }) + // 经典模式底部悬浮 Dock(日程/课程切换);曜光模式由底部导航栏轮换,无需 Dock + BottomDockHost(tab = tab, onSelect = { setTab(it) }) } } @@ -512,16 +741,31 @@ fun XuexiaotongScreen( Text("清空自建日程", fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Medium) } Spacer(Modifier.height(8.dp)) + // 未登录时禁用置灰,登录后才可退出 Box( modifier = Modifier .fillMaxWidth() .height(40.dp) .clip(RoundedCornerShape(20.dp)) - .background(MaterialTheme.colorScheme.error) - .clickable { viewModel.logout(); sideMenuOpen = false }, + .background( + if (loggedIn) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.15f) + ) + .then( + if (loggedIn) { + Modifier.clickable { viewModel.logout(); sideMenuOpen = false } + } else { + Modifier + } + ), contentAlignment = Alignment.Center ) { - Text("退出学习通登录", fontSize = 13.sp, color = Color.White, fontWeight = FontWeight.Medium) + Text( + "退出学习通登录", + fontSize = 13.sp, + color = if (loggedIn) Color.White else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium + ) } Spacer(Modifier.height(8.dp)) } @@ -548,13 +792,31 @@ private fun BottomSheetSwitchItem(label: String, checked: Boolean, onToggle: () } } -/* ==================== 底部悬浮 Dock ==================== */ + +/* ==================== 底部悬浮 Dock(经典模式) ==================== */ + +@Composable +private fun BoxScope.BottomDockHost( + tab: XuexiaotongSubTab, + onSelect: (XuexiaotongSubTab) -> Unit +) { + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .navigationBarsPadding() + .padding(bottom = 8.dp), + contentAlignment = Alignment.Center + ) { + BottomDock(current = tab, onSelect = onSelect) + } +} @Composable private fun BottomDock( - current: Tab, + current: XuexiaotongSubTab, modifier: Modifier = Modifier, - onSelect: (Tab) -> Unit + onSelect: (XuexiaotongSubTab) -> Unit ) { val primary = MaterialTheme.colorScheme.primary val surface = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.85f) @@ -568,7 +830,7 @@ private fun BottomDock( val dampedDragAnimation = remember(animationScope) { DampedDragAnimation( animationScope = animationScope, - initialValue = if (current == Tab.COURSE) 1f else 0f, + initialValue = if (current == XuexiaotongSubTab.COURSE) 1f else 0f, valueRange = 0f..1f, visibilityThreshold = 0.001f, initialScale = 1f, @@ -585,7 +847,7 @@ private fun BottomDock( if (startX >= half) 1f else 0f } animateToValue(target) - onSelect(if (target >= 0.5f) Tab.COURSE else Tab.SCHEDULE) + onSelect(if (target >= 0.5f) XuexiaotongSubTab.COURSE else XuexiaotongSubTab.SCHEDULE) }, onDrag = { _, dragAmount -> if (dragAmount.x != 0f) didDrag = true @@ -595,7 +857,7 @@ private fun BottomDock( ) } LaunchedEffect(current) { - dampedDragAnimation.animateToValue(if (current == Tab.COURSE) 1f else 0f) + dampedDragAnimation.animateToValue(if (current == XuexiaotongSubTab.COURSE) 1f else 0f) } Box( @@ -639,8 +901,8 @@ private fun BottomDock( Text( "日程", fontSize = 15.sp, - fontWeight = if (current == Tab.SCHEDULE) FontWeight.Bold else FontWeight.Normal, - color = if (current == Tab.SCHEDULE) primary else MaterialTheme.colorScheme.onSurfaceVariant + fontWeight = if (current == XuexiaotongSubTab.SCHEDULE) FontWeight.Bold else FontWeight.Normal, + color = if (current == XuexiaotongSubTab.SCHEDULE) primary else MaterialTheme.colorScheme.onSurfaceVariant ) } Box( @@ -652,8 +914,8 @@ private fun BottomDock( Text( "课程", fontSize = 15.sp, - fontWeight = if (current == Tab.COURSE) FontWeight.Bold else FontWeight.Normal, - color = if (current == Tab.COURSE) primary else MaterialTheme.colorScheme.onSurfaceVariant + fontWeight = if (current == XuexiaotongSubTab.COURSE) FontWeight.Bold else FontWeight.Normal, + color = if (current == XuexiaotongSubTab.COURSE) primary else MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -679,13 +941,46 @@ private fun ScheduleTab( year: Int, month: Int, onChangeMonth: (Int) -> Unit, - onWorkClick: (Work) -> Unit + onWorkClick: (Work) -> Unit, + isRadiant: Boolean = false, + pageBackdrop: Backdrop? = null, + loggedIn: Boolean = true ) { Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) ) { + // Radiant:滚动内容顶部停靠占位(渐变标题栏下方)+ 底部穿导航栏留白 + if (isRadiant) { + Spacer(Modifier.height(102.dp)) + } + if (isRadiant && pageBackdrop != null) { + // Radiant:玻璃日历卡(课表网格卡同款材质与 32dp 圆角),卡片自身左右各缩 6dp + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 6.dp) + .liquidGlassSurface( + backdrop = pageBackdrop, + shape = SmoothRoundedCornerShape(32.dp), + surfaceColor = liquidGlassTint() + ) + .padding(16.dp) + ) { + ScheduleCalendarContent( + works = works, + customEvents = customEvents, + showDone = showDone, + doneGray = doneGray, + year = year, + month = month, + onChangeMonth = onChangeMonth, + onWorkClick = onWorkClick, + loggedIn = loggedIn + ) + } + } else { // 日历卡片 Card( modifier = Modifier @@ -787,7 +1082,128 @@ private fun ScheduleTab( } } } - Spacer(Modifier.height(60.dp)) + } + Spacer(Modifier.height(if (isRadiant) 108.dp else 60.dp)) + } +} + +@Composable +private fun ScheduleCalendarContent( + works: List, + customEvents: List, + showDone: Boolean, + doneGray: Boolean, + year: Int, + month: Int, + onChangeMonth: (Int) -> Unit, + onWorkClick: (Work) -> Unit, + loggedIn: Boolean = true +) { + Column( + modifier = Modifier + .fillMaxWidth() + .pointerInput(Unit) { + var accumulated = 0f + var started = false + detectHorizontalDragGestures( + onDragStart = { accumulated = 0f }, + onDragEnd = { + if (started) { + if (accumulated < -40f) onChangeMonth(1) + else if (accumulated > 40f) onChangeMonth(-1) + } + }, + onDragCancel = {} + ) { change, dragAmount -> + change.consume() + started = true + accumulated += dragAmount + } + } + ) { + // 星期表头 + Row(Modifier.fillMaxWidth()) { + listOf("日", "一", "二", "三", "四", "五", "六").forEach { day -> + Text( + day, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium + ) + } + } + Spacer(Modifier.height(6.dp)) + + // 月份滑动动画 + val monthKey = year * 12 + (month - 1) + AnimatedContent( + targetState = monthKey, + transitionSpec = { + if (targetState > initialState) { + (slideInHorizontally { it } + fadeIn(tween(260))) togetherWith + (slideOutHorizontally { -it / 2 } + fadeOut(tween(220))) + } else { + (slideInHorizontally { -it } + fadeIn(tween(260))) togetherWith + (slideOutHorizontally { it / 2 } + fadeOut(tween(220))) + } + }, + label = "monthContent" + ) { key -> + val y = key / 12 + val m = key % 12 + 1 + val model = remember(key, works, customEvents, showDone) { + CalendarModel.buildMonth(y, m, works, customEvents, showDone) + } + Column { + model.rows.forEachIndexed { ri, row -> + MonthRowView(row = row, isShade = ri % 2 == 1, doneGray = doneGray, onWorkClick = onWorkClick) + } + if (model.noWorks) { + if (loggedIn) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(6.dp) + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), + CircleShape + ) + ) + Spacer(Modifier.width(6.dp)) + Text( + "暂无日程,您可以右上角新建自建日程", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + "暂无日程,您可以右上角新建自建日程\n或登录学习通同步作业列表", + fontSize = 12.sp, + lineHeight = 18.sp, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + Spacer(Modifier.height(8.dp)) + } + } } } @@ -922,7 +1338,10 @@ private fun CourseTab( progress: List, syncing: Boolean, syncMsg: com.ahu.ahutong.ui.screen.xuexiaotong.SyncProgress, - showEmptyCourses: Boolean + showEmptyCourses: Boolean, + loggedIn: Boolean, + isRadiant: Boolean = false, + pageBackdrop: Backdrop? = null ) { val filtered = if (showEmptyCourses) progress else progress.filter { it.totalCount > 0 } @@ -933,7 +1352,8 @@ private fun CourseTab( contentAlignment = Alignment.Center ) { Text( - "暂无课程进度,点击右上角同步获取", + if (loggedIn) "暂无课程进度,点击右上角同步获取" + else "登录后同步课程进度", fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -941,12 +1361,31 @@ private fun CourseTab( } else { LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 72.dp) + contentPadding = if (isRadiant) { + // Radiant:卡片自身左右各缩 6dp,列表两侧 6dp 留白 + PaddingValues(start = 6.dp, end = 6.dp, top = 102.dp, bottom = 108.dp) + } else { + PaddingValues(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 72.dp) + } ) { item(key = "__overview__") { - CourseOverviewCard(list = filtered) + CourseOverviewCard(list = filtered, isRadiant = isRadiant, pageBackdrop = pageBackdrop) } items(filtered, key = { it.courseId }) { p -> + if (isRadiant && pageBackdrop != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .liquidGlassSurface( + backdrop = pageBackdrop, + shape = SmoothRoundedCornerShape(32.dp), + surfaceColor = liquidGlassTint() + ) + ) { + CourseCardContent(p) + } + } else { Card( modifier = Modifier .fillMaxWidth() @@ -956,46 +1395,8 @@ private fun CourseTab( containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) ) ) { - Column(Modifier.padding(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - p.name, - modifier = Modifier.weight(1f), - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - if (p.totalCount > 0) "${p.percent}%" else "暂无任务点", - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - color = if (p.totalCount > 0) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Spacer(Modifier.height(8.dp)) - Box( - modifier = Modifier - .fillMaxWidth() - .height(6.dp) - .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(3.dp)) - ) { - Box( - modifier = Modifier - .fillMaxWidth(p.percent / 100f) - .height(6.dp) - .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(3.dp)) - ) - } - Spacer(Modifier.height(8.dp)) - Text( - if (p.totalCount > 0) "已完成任务点 ${p.doneCount}/${p.totalCount}" - else "暂无任务点", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + CourseCardContent(p) + } } } } @@ -1003,11 +1404,69 @@ private fun CourseTab( } @Composable -private fun CourseOverviewCard(list: List) { +private fun CourseCardContent(p: CourseProgress) { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + p.name, + modifier = Modifier.weight(1f), + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + if (p.totalCount > 0) "${p.percent}%" else "暂无任务点", + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = if (p.totalCount > 0) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(Modifier.height(8.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(3.dp)) + ) { + Box( + modifier = Modifier + .fillMaxWidth(p.percent / 100f) + .height(6.dp) + .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(3.dp)) + ) + } + Spacer(Modifier.height(8.dp)) + Text( + if (p.totalCount > 0) "已完成任务点 ${p.doneCount}/${p.totalCount}" + else "暂无任务点", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun CourseOverviewCard(list: List, isRadiant: Boolean = false, pageBackdrop: Backdrop? = null) { val totalDone = list.sumOf { it.doneCount } val totalAll = list.sumOf { it.totalCount } val percent = if (totalAll > 0) (totalDone * 100 / totalAll).coerceAtMost(100) else 0 + if (isRadiant && pageBackdrop != null) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .liquidGlassSurface( + backdrop = pageBackdrop, + shape = SmoothRoundedCornerShape(32.dp), + surfaceColor = liquidGlassTint() + ) + ) { + CourseOverviewStatsRow(list, totalDone, totalAll, percent) + } + } else { Card( modifier = Modifier .fillMaxWidth() @@ -1017,26 +1476,37 @@ private fun CourseOverviewCard(list: List) { containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) ) ) { - Row(Modifier.padding(vertical = 16.dp)) { - CourseOverviewStat( - value = "${list.size}", - label = "门课程", - valueColor = MaterialTheme.colorScheme.primary, - modifier = Modifier.weight(1f) - ) - CourseOverviewStat( - value = "$percent%", - label = "总进度", - valueColor = MaterialTheme.colorScheme.primary, - modifier = Modifier.weight(1f) - ) - CourseOverviewStat( - value = if (totalAll > 0) "$totalDone/$totalAll" else "0/0", - label = "已完成任务点", - valueColor = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f) - ) - } + CourseOverviewStatsRow(list, totalDone, totalAll, percent) + } + } +} + +@Composable +private fun CourseOverviewStatsRow( + list: List, + totalDone: Int, + totalAll: Int, + percent: Int +) { + Row(Modifier.padding(vertical = 16.dp)) { + CourseOverviewStat( + value = "${list.size}", + label = "门课程", + valueColor = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + CourseOverviewStat( + value = "$percent%", + label = "总进度", + valueColor = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + CourseOverviewStat( + value = if (totalAll > 0) "$totalDone/$totalAll" else "0/0", + label = "已完成任务点", + valueColor = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) } } @@ -1064,4 +1534,4 @@ private fun CourseOverviewStat( color = MaterialTheme.colorScheme.onSurfaceVariant ) } -} \ No newline at end of file +} 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..3a0000b3 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 @@ -1,21 +1,22 @@ -package com.ahu.ahutong.ui.state - -import androidx.lifecycle.ViewModel +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.model.AppThemeMode +import com.ahu.ahutong.data.model.UiStyle 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 javax.inject.Inject - -@HiltViewModel +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 javax.inject.Inject + +@HiltViewModel class PreferencesViewModel @Inject constructor( private val preferencesManager: PreferencesManager, private val behaviorRuntime: BehaviorPredictionRuntime @@ -32,25 +33,28 @@ class PreferencesViewModel @Inject constructor( private val _behaviorRetentionDays = MutableStateFlow(30) val behaviorRetentionDays: StateFlow = _behaviorRetentionDays.asStateFlow() - + 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 _isShowAllCourse = MutableStateFlow(false) + val isShowAllCourse: StateFlow = _isShowAllCourse.asStateFlow() + + private val _useLiquidGlass = MutableStateFlow(true) + val useLiquidGlass: StateFlow = _useLiquidGlass.asStateFlow() + + private val _uiStyle = MutableStateFlow(UiStyle.RADIANT_UI) + val uiStyle: StateFlow = _uiStyle.asStateFlow() + private val _themeColor = MutableStateFlow(null) val themeColor: StateFlow = _themeColor.asStateFlow() private val _appThemeMode = MutableStateFlow(AppThemeMode.FOLLOW_SYSTEM) val appThemeMode: StateFlow = _appThemeMode.asStateFlow() - + private val _courseReminderEnabled = MutableStateFlow(false) val courseReminderEnabled: StateFlow = _courseReminderEnabled.asStateFlow() @@ -75,22 +79,27 @@ class PreferencesViewModel @Inject constructor( preferencesManager.themeColor.collect { _themeColor.value = it } - } - viewModelScope.launch { - preferencesManager.showQRCode.collect { - _showQRCode.value = it - } - } - viewModelScope.launch { - preferencesManager.isShowAllCourse.collect { - _isShowAllCourse.value = it - } - } - viewModelScope.launch { - preferencesManager.useLiquidGlass.collect { - _useLiquidGlass.value = it - } - } + } + viewModelScope.launch { + preferencesManager.showQRCode.collect { + _showQRCode.value = it + } + } + viewModelScope.launch { + preferencesManager.isShowAllCourse.collect { + _isShowAllCourse.value = it + } + } + viewModelScope.launch { + preferencesManager.useLiquidGlass.collect { + _useLiquidGlass.value = it + } + } + viewModelScope.launch { + preferencesManager.uiStyle.collect { + _uiStyle.value = it + } + } viewModelScope.launch { preferencesManager.courseReminderEnabled.collect { _courseReminderEnabled.value = it @@ -150,31 +159,46 @@ class PreferencesViewModel @Inject constructor( fun setBehaviorRetentionDays(value: Int) { viewModelScope.launch { preferencesManager.setBehaviorRetentionDays(value) } } - + fun setShowQRCode(value: Boolean) { viewModelScope.launch { val oldValue = _showQRCode.value preferencesManager.setShowQRCode(value) behaviorRuntime.recordCommittedMutation(MutationId.HOME_DEFAULT_QR_CHANGED, oldValue, value) } - } - + } + fun setIsShowAllCourse(value: Boolean) { viewModelScope.launch { val oldValue = _isShowAllCourse.value preferencesManager.setIsShowAllCourse(value) behaviorRuntime.recordCommittedMutation(MutationId.SCHEDULE_OVERVIEW_CHANGED, oldValue, value) - } - } - + } + } + fun setUseLiquidGlass(value: Boolean) { viewModelScope.launch { val oldValue = _useLiquidGlass.value preferencesManager.setUseLiquidGlass(value) behaviorRuntime.recordCommittedMutation(MutationId.LIQUID_GLASS_CHANGED, oldValue, value) - } - } - + } + } + + fun setUiStyle(value: UiStyle) { + viewModelScope.launch { + val oldValue = _uiStyle.value + preferencesManager.setUiStyle(value) + if (oldValue != value) { + behaviorRuntime.recordCommittedMutation( + MutationId.LIQUID_GLASS_CHANGED, + oldValue.storageValue, + value.storageValue, + coarseValueBucket = value.storageValue + ) + } + } + } + fun setCourseReminderEnabled(value: Boolean) { viewModelScope.launch { val oldValue = _courseReminderEnabled.value diff --git a/app/src/main/java/com/ahu/ahutong/ui/theme/AHUTheme.kt b/app/src/main/java/com/ahu/ahutong/ui/theme/AHUTheme.kt index c845bf12..7ab24c8e 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 @@ -20,7 +20,9 @@ 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.data.model.UiStyle import com.ahu.ahutong.ui.components.LocalIsLiquidGlassEnabled +import com.ahu.ahutong.ui.components.LocalUiStyle import com.ahu.ahutong.ui.state.PreferencesViewModel import com.kyant.monet.LocalTonalPalettes import com.kyant.monet.TonalPalettes.Companion.toTonalPalettes @@ -34,7 +36,8 @@ 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 uiStyle by preferencesViewModel.uiStyle.collectAsState() + val useLiquidGlass = uiStyle != UiStyle.ORIGINAL val isDarkTheme = themeMode.resolve(isSystemInDarkTheme()) val configuration = LocalConfiguration.current val themeConfiguration = remember(configuration, isDarkTheme) { @@ -81,6 +84,7 @@ fun AHUTheme(content: @Composable () -> Unit) { CompositionLocalProvider( LocalContentColor provides if (isDarkTheme) 100.n1 else 0.n1, LocalIsLiquidGlassEnabled provides useLiquidGlass, + LocalUiStyle provides uiStyle, content = content ) } diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 00000000..3001d289 --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,27 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_aiming.xml b/app/src/main/res/drawable/ic_aiming.xml new file mode 100644 index 00000000..cec8a46e --- /dev/null +++ b/app/src/main/res/drawable/ic_aiming.xml @@ -0,0 +1,42 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_announcement.xml b/app/src/main/res/drawable/ic_announcement.xml new file mode 100644 index 00000000..55a35d87 --- /dev/null +++ b/app/src/main/res/drawable/ic_announcement.xml @@ -0,0 +1,35 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_bathroom_pay.xml b/app/src/main/res/drawable/ic_bathroom_pay.xml index 5d0cc5ea..73283573 100644 --- a/app/src/main/res/drawable/ic_bathroom_pay.xml +++ b/app/src/main/res/drawable/ic_bathroom_pay.xml @@ -1,9 +1,56 @@ + android:viewportWidth="48" + android:viewportHeight="48"> - + android:fillColor="@android:color/transparent" + android:strokeColor="#FF333333" + android:strokeWidth="4" + android:strokeLineCap="round" + android:strokeLineJoin="round" + android:pathData="M27 20V22H9V20C9 16.6863 13.0294 14 18 14C22.9706 14 27 16.6863 27 20Z" /> + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_check_one.xml b/app/src/main/res/drawable/ic_check_one.xml new file mode 100644 index 00000000..e891d4c9 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_one.xml @@ -0,0 +1,20 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_clear.xml b/app/src/main/res/drawable/ic_clear.xml new file mode 100644 index 00000000..a88d22d9 --- /dev/null +++ b/app/src/main/res/drawable/ic_clear.xml @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_config.xml b/app/src/main/res/drawable/ic_config.xml new file mode 100644 index 00000000..8683274d --- /dev/null +++ b/app/src/main/res/drawable/ic_config.xml @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 00000000..664822df --- /dev/null +++ b/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,42 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_electricity_pay.xml b/app/src/main/res/drawable/ic_electricity_pay.xml index 7776dcb0..40f96ecc 100644 --- a/app/src/main/res/drawable/ic_electricity_pay.xml +++ b/app/src/main/res/drawable/ic_electricity_pay.xml @@ -1,9 +1,12 @@ + android:viewportWidth="48" + android:viewportHeight="48"> - + android:fillColor="@android:color/transparent" + android:strokeColor="#FF333333" + android:strokeWidth="4" + android:strokeLineJoin="round" + android:pathData="M19 4H37L26 18H41L17 44L22 25H8L19 4Z" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_evaluation.xml b/app/src/main/res/drawable/ic_evaluation.xml index 246d4e41..1b79c9d1 100644 --- a/app/src/main/res/drawable/ic_evaluation.xml +++ b/app/src/main/res/drawable/ic_evaluation.xml @@ -1,10 +1,32 @@ - + android:viewportWidth="48" + android:viewportHeight="48"> - + android:fillColor="@android:color/transparent" + android:strokeColor="#FF333333" + android:strokeWidth="4" + android:strokeLineCap="round" + android:strokeLineJoin="round" + android:pathData="M12 19H6V6H42V19H36" /> + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_exam.xml b/app/src/main/res/drawable/ic_exam.xml index 212a1806..17dc4183 100644 --- a/app/src/main/res/drawable/ic_exam.xml +++ b/app/src/main/res/drawable/ic_exam.xml @@ -1,9 +1,25 @@ + android:viewportWidth="48" + android:viewportHeight="48"> - + android:fillColor="@android:color/transparent" + android:strokeColor="#FF333333" + android:strokeWidth="4" + android:strokeLineCap="round" + android:strokeLineJoin="round" + android:pathData="M9.85786 32.7574C6.23858 33.8432 4 35.3432 4 37C4 40.3137 12.9543 43 24 43C35.0457 43 44 40.3137 44 37C44 35.3432 41.7614 33.8432 38.1421 32.7574" /> + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_filter.xml b/app/src/main/res/drawable/ic_filter.xml new file mode 100644 index 00000000..2652dbf9 --- /dev/null +++ b/app/src/main/res/drawable/ic_filter.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_find.xml b/app/src/main/res/drawable/ic_find.xml new file mode 100644 index 00000000..f2d064d2 --- /dev/null +++ b/app/src/main/res/drawable/ic_find.xml @@ -0,0 +1,36 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_grade.xml b/app/src/main/res/drawable/ic_grade.xml index 10c2a540..d535d91b 100644 --- a/app/src/main/res/drawable/ic_grade.xml +++ b/app/src/main/res/drawable/ic_grade.xml @@ -1,9 +1,20 @@ + android:viewportWidth="48" + android:viewportHeight="48"> - + android:fillColor="@android:color/transparent" + android:strokeColor="#FF333333" + android:strokeWidth="4" + android:strokeLineCap="round" + android:strokeLineJoin="round" + android:pathData="M5 24C5 34.4934 13.5066 43 24 43V26C24 24.8954 24.8954 24 26 24H43C43 13.5066 34.4934 5 24 5C13.5066 5 5 13.5066 5 24Z" /> + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_income.xml b/app/src/main/res/drawable/ic_income.xml new file mode 100644 index 00000000..644194f9 --- /dev/null +++ b/app/src/main/res/drawable/ic_income.xml @@ -0,0 +1,56 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_log.xml b/app/src/main/res/drawable/ic_log.xml new file mode 100644 index 00000000..8aa66958 --- /dev/null +++ b/app/src/main/res/drawable/ic_log.xml @@ -0,0 +1,34 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_logout.xml b/app/src/main/res/drawable/ic_logout.xml new file mode 100644 index 00000000..14852edc --- /dev/null +++ b/app/src/main/res/drawable/ic_logout.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_more_all.xml b/app/src/main/res/drawable/ic_more_all.xml new file mode 100644 index 00000000..8d367238 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_all.xml @@ -0,0 +1,31 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_nav_degree_hat.xml b/app/src/main/res/drawable/ic_nav_degree_hat.xml new file mode 100644 index 00000000..1088e810 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_degree_hat.xml @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_nav_home.xml b/app/src/main/res/drawable/ic_nav_home.xml new file mode 100644 index 00000000..36e93364 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_home.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_nav_plan.xml b/app/src/main/res/drawable/ic_nav_plan.xml new file mode 100644 index 00000000..1645ad6b --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_plan.xml @@ -0,0 +1,37 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_nav_schedule.xml b/app/src/main/res/drawable/ic_nav_schedule.xml new file mode 100644 index 00000000..bc1638a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_schedule.xml @@ -0,0 +1,38 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_nav_settings.xml b/app/src/main/res/drawable/ic_nav_settings.xml new file mode 100644 index 00000000..908c8439 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_settings.xml @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_nav_tools.xml b/app/src/main/res/drawable/ic_nav_tools.xml new file mode 100644 index 00000000..be69f5ab --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_tools.xml @@ -0,0 +1,39 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_network_recharge.xml b/app/src/main/res/drawable/ic_network_recharge.xml index f0a400b4..a3c75866 100644 --- a/app/src/main/res/drawable/ic_network_recharge.xml +++ b/app/src/main/res/drawable/ic_network_recharge.xml @@ -1,12 +1,62 @@ + android:viewportWidth="48" + android:viewportHeight="48"> + android:fillColor="@android:color/transparent" + android:strokeColor="#FF333333" + android:strokeWidth="4" + android:strokeLineCap="round" + android:strokeLineJoin="round" + android:pathData="M44 31C44 36.5228 39.5228 41 34 41C32.2091 41 30.5281 40.5292 29.0741 39.7046C26.5143 38.2529 24.6579 35.7046 24.1436 32.6983C24.0492 32.1463 24 31.5789 24 31C24 28.4323 24.9678 26.0906 26.5585 24.3198C28.3892 22.2818 31.0449 21 34 21C39.5228 21 44 25.4772 44 31Z" /> - + android:fillColor="@android:color/transparent" + android:strokeColor="#FF333333" + android:strokeWidth="4" + android:strokeLineCap="round" + android:strokeLineJoin="round" + android:pathData="M34 12V20V21C31.0449 21 28.3892 22.2818 26.5585 24.3198C24.9678 26.0906 24 28.4323 24 31C24 31.5789 24.0492 32.1463 24.1436 32.6983C24.6579 35.7046 26.5143 38.2529 29.0741 39.7046C26.4116 40.5096 22.8776 41 19 41C10.7157 41 4 38.7614 4 36V28V20V12" /> + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_peoples.xml b/app/src/main/res/drawable/ic_peoples.xml new file mode 100644 index 00000000..8fd23c25 --- /dev/null +++ b/app/src/main/res/drawable/ic_peoples.xml @@ -0,0 +1,35 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_permission.xml b/app/src/main/res/drawable/ic_permission.xml new file mode 100644 index 00000000..4904d6ca --- /dev/null +++ b/app/src/main/res/drawable/ic_permission.xml @@ -0,0 +1,40 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_phonebook.xml b/app/src/main/res/drawable/ic_phonebook.xml index a81e715f..88117c38 100644 --- a/app/src/main/res/drawable/ic_phonebook.xml +++ b/app/src/main/res/drawable/ic_phonebook.xml @@ -1,9 +1,54 @@ + android:viewportWidth="48" + android:viewportHeight="48"> - + android:fillColor="@android:color/transparent" + android:strokeColor="#FF333333" + android:strokeWidth="4" + android:strokeLineJoin="round" + android:pathData="M10 6C10 4.89543 10.8954 4 12 4H40C41.1046 4 42 4.89543 42 6V42C42 43.1046 41.1046 44 40 44H12C10.8954 44 10 43.1046 10 42V6Z" /> + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 00000000..ce6315fa --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,28 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_repository.xml b/app/src/main/res/drawable/ic_repository.xml index d437e6ca..ae9e6887 100644 --- a/app/src/main/res/drawable/ic_repository.xml +++ b/app/src/main/res/drawable/ic_repository.xml @@ -1,9 +1,38 @@ + android:viewportWidth="48" + android:viewportHeight="48"> - + android:fillColor="@android:color/transparent" + android:strokeColor="#FF333333" + android:strokeWidth="4" + android:strokeLineJoin="round" + android:pathData="M32 6H22V42H32V6Z" /> + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_round_business_24.xml b/app/src/main/res/drawable/ic_round_business_24.xml index c3b55a22..4b3eaa68 100644 --- a/app/src/main/res/drawable/ic_round_business_24.xml +++ b/app/src/main/res/drawable/ic_round_business_24.xml @@ -1,5 +1,26 @@ - - - - - + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_schedule.xml b/app/src/main/res/drawable/ic_schedule.xml index 035c3355..bb3422c0 100644 --- a/app/src/main/res/drawable/ic_schedule.xml +++ b/app/src/main/res/drawable/ic_schedule.xml @@ -1,9 +1,55 @@ + android:viewportWidth="48" + android:viewportHeight="48"> - + android:fillColor="@android:color/transparent" + android:strokeColor="#FF333333" + android:strokeWidth="4" + android:strokeLineCap="round" + android:strokeLineJoin="round" + android:pathData="M6 4H42Q44 4 44 6V42Q44 44 42 44H6Q4 44 4 42V6Q4 4 6 4Z" /> + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_send.xml b/app/src/main/res/drawable/ic_send.xml new file mode 100644 index 00000000..b535c5b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_send.xml @@ -0,0 +1,20 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_setting_config.xml b/app/src/main/res/drawable/ic_setting_config.xml new file mode 100644 index 00000000..719c3b6a --- /dev/null +++ b/app/src/main/res/drawable/ic_setting_config.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_topic.xml b/app/src/main/res/drawable/ic_topic.xml new file mode 100644 index 00000000..6241e9c5 --- /dev/null +++ b/app/src/main/res/drawable/ic_topic.xml @@ -0,0 +1,41 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_update.xml b/app/src/main/res/drawable/ic_update.xml new file mode 100644 index 00000000..202b2488 --- /dev/null +++ b/app/src/main/res/drawable/ic_update.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_weather.xml b/app/src/main/res/drawable/ic_weather.xml index d5f2e784..29214640 100644 --- a/app/src/main/res/drawable/ic_weather.xml +++ b/app/src/main/res/drawable/ic_weather.xml @@ -1,19 +1,39 @@ - + android:viewportWidth="48" + android:viewportHeight="48"> - + android:fillColor="@android:color/transparent" + android:strokeColor="#FF333333" + android:strokeWidth="4" + android:strokeLineCap="round" + android:strokeLineJoin="round" + android:pathData="M30.7826 24.5652C34.5285 24.5652 37.5652 21.5285 37.5652 17.7826C37.5652 14.0367 34.5285 11 30.7826 11C27.4338 11 24.6518 13.427 24.0996 16.618" /> + + + android:fillColor="#FF333333" + android:pathData="M44 21C45.1046 21 46 20.1046 46 19C46 17.8954 45.1046 17 44 17C42.8954 17 42 17.8954 42 19C42 20.1046 42.8954 21 44 21Z" /> + + - + android:strokeLineJoin="round" + android:pathData="M22.2426 24.7574C21.1569 23.6716 19.6569 23 18 23C14.6863 23 12 25.6863 12 29C12 30.6569 12.6716 32.1569 13.7574 33.2426" /> + \ No newline at end of file diff --git a/app/src/main/res/drawable/lost_and_found.xml b/app/src/main/res/drawable/lost_and_found.xml index 764d58bc..238954f6 100644 --- a/app/src/main/res/drawable/lost_and_found.xml +++ b/app/src/main/res/drawable/lost_and_found.xml @@ -1,23 +1,23 @@ - + android:width="24dp" + android:height="24dp" + android:viewportWidth="48" + android:viewportHeight="48"> - + android:strokeColor="#FF333333" + android:strokeWidth="4" + android:strokeLineJoin="round" + android:pathData="M24 44C29.5228 44 34.5228 41.7614 38.1421 38.1421C41.7614 34.5228 44 29.5228 44 24C44 18.4772 41.7614 13.4772 38.1421 9.85786C34.5228 6.23858 29.5228 4 24 4C18.4772 4 13.4772 6.23858 9.85786 9.85786C6.23858 13.4772 4 18.4772 4 24C4 29.5228 6.23858 34.5228 9.85786 38.1421C13.4772 41.7614 18.4772 44 24 44Z" /> - + android:strokeLineJoin="round" + android:pathData="M24 28.6248V24.6248C27.3137 24.6248 30 21.9385 30 18.6248C30 15.3111 27.3137 12.6248 24 12.6248C20.6863 12.6248 18 15.3111 18 18.6248" /> + \ No newline at end of file From edec99f9c5b23d5c761dcfe8f02f84a08d5d39b9 Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Wed, 2 Sep 2026 20:20:21 +0800 Subject: [PATCH 07/29] feat(payments): add native recharge and electricity controllers --- .../main/java/com/ahu/ahutong/MainActivity.kt | 6 - .../crawler/model/ycard/CardPayRequest.kt | 63 +- .../java/com/ahu/ahutong/data/dao/AHUCache.kt | 14 + .../ahutong/data/model/RoomSelectionInfo.kt | 19 +- .../ui/screen/main/CardBalanceDeposit.kt | 55 +- .../ahutong/ui/screen/main/CmbCardRecharge.kt | 2002 ----------------- .../ui/screen/main/CmbRechargeNativePanel.kt | 474 ---- .../ui/screen/main/CmbRechargePageStyle.kt | 310 --- .../ui/screen/main/ElectricityDeposit.kt | 189 +- .../ui/state/CardBalanceDepositViewModel.kt | 5 +- .../ui/state/ElectricityDepositViewModel.kt | 200 +- .../ui/screen/main/CardPayRequestTest.kt | 47 + .../screen/main/CmbRechargePageStyleTest.kt | 514 ----- .../ui/state/ElectricityControllerTest.kt | 28 + 14 files changed, 392 insertions(+), 3534 deletions(-) delete mode 100644 app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbCardRecharge.kt delete mode 100644 app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbRechargeNativePanel.kt delete mode 100644 app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbRechargePageStyle.kt create mode 100644 app/src/test/java/com/ahu/ahutong/ui/screen/main/CardPayRequestTest.kt delete mode 100644 app/src/test/java/com/ahu/ahutong/ui/screen/main/CmbRechargePageStyleTest.kt create mode 100644 app/src/test/java/com/ahu/ahutong/ui/state/ElectricityControllerTest.kt diff --git a/app/src/main/java/com/ahu/ahutong/MainActivity.kt b/app/src/main/java/com/ahu/ahutong/MainActivity.kt index 80a8fb5c..356363b4 100644 --- a/app/src/main/java/com/ahu/ahutong/MainActivity.kt +++ b/app/src/main/java/com/ahu/ahutong/MainActivity.kt @@ -32,7 +32,6 @@ 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.CmbRechargeAutomationController import com.ahu.ahutong.ui.state.AboutViewModel import com.ahu.ahutong.ui.state.DiscoveryViewModel import com.ahu.ahutong.ui.state.LoginViewModel @@ -208,11 +207,6 @@ class MainActivity : ComponentActivity() { super.onStop() } - override fun onDestroy() { - CmbRechargeAutomationController.discard() - super.onDestroy() - } - override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) 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/dao/AHUCache.kt b/app/src/main/java/com/ahu/ahutong/data/dao/AHUCache.kt index 38b1b2f3..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 @@ -7,6 +7,7 @@ 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 @@ -666,6 +667,19 @@ object AHUCache { ) } + 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) + } + /** * 获取房间选择信息 * @return RoomSelectionInfo? 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/ui/screen/main/CardBalanceDeposit.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/CardBalanceDeposit.kt index 3f45648c..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 @@ -84,8 +84,7 @@ fun CardBalanceDeposit( val cardInfo = viewModel.cardInfo.collectAsState() val accountState by viewModel.accountState.collectAsState() - val agriculturalPaymentState by viewModel.paymentState.collectAsState() - val cmbRechargeState by CmbRechargeAutomationController.state.collectAsState() + val paymentState by viewModel.paymentState.collectAsState() var showAlipayConfirmDialog by remember { mutableStateOf(false) } var copyCampusCardInfo by remember { mutableStateOf(false) } @@ -97,21 +96,12 @@ fun CardBalanceDeposit( val currentUser = remember { AHUCache.getCurrentUser() } val campusCardUserName = currentUser?.name.orEmpty() val campusCardStudentId = currentUser?.xh.orEmpty() - val paymentState = when (selectedRechargeBank) { - CardRechargeBank.CHINA_MERCHANTS_BANK -> cmbRechargeState.toPaymentState() - CardRechargeBank.AGRICULTURAL_BANK -> agriculturalPaymentState - CardRechargeBank.ALIPAY, - null -> PaymentState.Idle - } - fun selectRechargeBank(bank: CardRechargeBank) { if (paymentState == PaymentState.Loading) return selectedRechargeBank = bank AHUCache.setCardRechargeBank(bank) if (bank == CardRechargeBank.ALIPAY) copyCampusCardInfo = false viewModel.resetPaymentState() - CmbRechargeAutomationController.resetPaymentState() - CmbRechargeAutomationController.onBankSelected(context, bank) } fun submitRecharge() { @@ -119,11 +109,17 @@ fun CardBalanceDeposit( CardRechargeBank.ALIPAY -> showAlipayConfirmDialog = true CardRechargeBank.CHINA_MERCHANTS_BANK -> { behaviorReporter.organic(AppActionId.SUBMIT_CMB_CARD_RECHARGE) - CmbRechargeAutomationController.submit(context = context, amount = amount) + viewModel.charge( + value = amount, + bank = CardRechargeBank.CHINA_MERCHANTS_BANK + ) } CardRechargeBank.AGRICULTURAL_BANK -> { behaviorReporter.organic(AppActionId.SUBMIT_CARD_RECHARGE) - viewModel.charge(amount) + viewModel.charge( + value = amount, + bank = CardRechargeBank.AGRICULTURAL_BANK + ) } null -> Unit } @@ -131,9 +127,6 @@ fun CardBalanceDeposit( LaunchedEffect(Unit) { viewModel.load() - if (selectedRechargeBank == CardRechargeBank.CHINA_MERCHANTS_BANK) { - (context as? android.app.Activity)?.let(CmbRechargeAutomationController::schedulePreload) - } } LaunchedEffect(mockRefreshRevision) { @@ -147,18 +140,10 @@ fun CardBalanceDeposit( delay(1_000L) viewModel.load() delay(PAYMENT_RESULT_DISPLAY_DURATION_MS - 1_000L) - if (selectedRechargeBank == CardRechargeBank.CHINA_MERCHANTS_BANK) { - CmbRechargeAutomationController.resetPaymentState() - } else { - viewModel.resetPaymentState() - } + viewModel.resetPaymentState() } else if (paymentState is PaymentState.Error) { delay(PAYMENT_RESULT_DISPLAY_DURATION_MS) - if (selectedRechargeBank == CardRechargeBank.CHINA_MERCHANTS_BANK) { - CmbRechargeAutomationController.resetPaymentState() - } else { - viewModel.resetPaymentState() - } + viewModel.resetPaymentState() } } val canConfirm = paymentState == PaymentState.Idle && when (selectedRechargeBank) { @@ -429,13 +414,6 @@ fun CardBalanceDeposit( } } - if (cmbRechargeState.phase == CmbRechargePaymentPhase.PASSWORD_REQUIRED) { - CmbRechargeQueryPasswordDialog( - onCancel = CmbRechargeAutomationController::cancelPassword, - onConfirm = CmbRechargeAutomationController::submitPassword - ) - } - } } @@ -447,17 +425,6 @@ private val CardRechargeBank.displayName: String CardRechargeBank.ALIPAY -> "支付宝" } -private fun CmbRechargeAutomationState.toPaymentState(): PaymentState = when (phase) { - CmbRechargePaymentPhase.IDLE, - CmbRechargePaymentPhase.PASSWORD_REQUIRED -> PaymentState.Idle - - CmbRechargePaymentPhase.LOADING -> PaymentState.Loading - CmbRechargePaymentPhase.SUCCESS -> PaymentState.Success("招商银行") - CmbRechargePaymentPhase.ERROR -> PaymentState.Error( - errorMessage ?: "招商银行充值失败,请重试" - ) -} - private enum class CampusCardIdentityCopyState { Complete, Partial, 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 a167bfb3..00000000 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbCardRecharge.kt +++ /dev/null @@ -1,2002 +0,0 @@ -package com.ahu.ahutong.ui.screen.main - -import android.annotation.SuppressLint -import android.app.Activity -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.os.Looper -import android.os.MessageQueue -import android.os.SystemClock -import android.util.Log -import android.view.View -import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.Toast -import android.webkit.WebChromeClient -import android.webkit.JavascriptInterface -import android.webkit.WebResourceError -import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse -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.statusBarsPadding -import androidx.compose.foundation.layout.width -import com.ahu.ahutong.ui.components.AppCircularProgressIndicator -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.draw.clipToBounds -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.zIndex -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.data.dao.AHUCache -import com.ahu.ahutong.data.model.CardRechargeBank -import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape -import com.ahu.ahutong.personalization.action.AppActionId -import com.ahu.ahutong.personalization.ui.rememberBehaviorActionReporter -import com.google.gson.Gson -import com.ahu.ahutong.ui.components.AppPageHeader -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import okhttp3.Cookie -import java.net.URI -import kotlin.coroutines.resume - -internal data class CmbRechargeNormalizedBounds( - val left: Float, - val top: Float, - val width: Float, - val height: Float -) - -private const val CMB_NATIVE_BOOTSTRAP_TIMEOUT_MS = 15_000L -private const val CMB_PASSWORD_DISPATCH_TIMEOUT_MS = 15_000L -private const val CMB_SUCCESS_CONFIRMATION_TIMEOUT_MS = 8_000L -internal const val CMB_RECHARGE_PRELOAD_VALIDITY_MS = 3 * 60 * 1_000L - -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); -})(); -""" - -private val CMB_NATIVE_STATE_BRIDGE_SCRIPT = """ -(function(){ - function findRechargeComponent(){ - var root = document.querySelector('#app'); - var queue = root && root.__vue__ ? [root.__vue__] : []; - var seen = []; - while (queue.length) { - var current = queue.shift(); - if (!current || seen.indexOf(current) >= 0) continue; - seen.push(current); - if (typeof current.rechargeOrders === 'function' && Array.isArray(current.cardList)) { - return current; - } - if (current.${'$'}children) queue = queue.concat(current.${'$'}children); - } - return null; - } - function publish(){ - var component = findRechargeComponent(); - if (!component || !component.cardList.length) return; - var account = component.cardList[component.cardIndex || 0] || component.cardList[0]; - var methods = (component.payType || []).map(function(item, index){ - return { - pageIndex: index, - name: String(item.payPrdName || ('支付方式 ' + (index + 1))) - }; - }); - var payload = JSON.stringify({ - studentNumber: String(account.empno || ''), - balance: Number(account.balance || 0), - paymentMethods: methods - }); - if (payload === window.__ahutongRechargeLastPayload) return; - window.__ahutongRechargeLastPayload = payload; - window.AhuTongRechargeBridge.onRechargeState(payload); - } - window.__ahutongPublishRechargeState = publish; - if (!window.__ahutongRechargeStateObserverInstalled) { - window.__ahutongRechargeStateObserverInstalled = true; - window.setInterval(publish, 500); - } - publish(); -})(); -""" - -private const val CMB_NATIVE_PAYMENT_UI_SCRIPT = """ -(function(){ - function visible(node){ - if (!node) return false; - var style = window.getComputedStyle(node); - return style.display !== 'none' && style.visibility !== 'hidden' && - node.getClientRects().length > 0; - } - function notify(){ - var sheets = Array.from(document.querySelectorAll('.van-action-sheet')); - var sheet = sheets.find(visible); - var title = sheet - ? ((sheet.querySelector('.van-action-sheet__header') || {}).innerText || '') - : ''; - var passwordDots = sheet - ? Array.from(sheet.querySelectorAll('.van-password-input__security i')).filter(visible).length - : 0; - var toast = Array.from(document.querySelectorAll('.van-toast--fail')).find(visible); - window.AhuTongRechargeBridge.onPaymentUiState(JSON.stringify({ - passwordRequired: title.indexOf('查询密码') >= 0 && passwordDots === 0, - error: toast ? (toast.innerText || '') : '' - })); - } - if (!window.__ahutongPaymentUiObserverInstalled) { - if (!document.body) return 'body-not-ready'; - var observer = new MutationObserver(notify); - observer.observe(document.body, { - subtree: true, - childList: true, - attributes: true, - attributeFilter: ['style', 'class'] - }); - window.__ahutongPaymentUiObserverInstalled = true; - window.__ahutongPaymentUiObserver = observer; - window.setInterval(notify, 250); - } - notify(); -})(); -""" - -internal enum class CmbRechargePaymentPhase { - IDLE, - LOADING, - PASSWORD_REQUIRED, - SUCCESS, - ERROR -} - -internal data class CmbRechargeAutomationState( - val phase: CmbRechargePaymentPhase = CmbRechargePaymentPhase.IDLE, - val errorMessage: String? = null -) - -internal fun isCmbRechargeSessionFresh( - readyAtElapsedMs: Long, - nowElapsedMs: Long -): Boolean = readyAtElapsedMs > 0L && - nowElapsedMs >= readyAtElapsedMs && - nowElapsedMs - readyAtElapsedMs < CMB_RECHARGE_PRELOAD_VALIDITY_MS - -internal fun canDispatchCmbRecharge( - amount: String?, - password: String?, - hasFreshSession: Boolean -): Boolean = !amount.isNullOrBlank() && - !password.isNullOrBlank() && - hasFreshSession - -internal fun canDispatchCmbPassword( - password: String?, - dispatchInProgress: Boolean, - hasWebView: Boolean -): Boolean = !password.isNullOrBlank() && !dispatchInProgress && hasWebView - -internal fun isCmbSessionExpiredMessage(message: String): Boolean { - val normalized = message.trim().lowercase() - return listOf( - "登录失效", - "登录已失效", - "登录过期", - "登录已过期", - "登录超时", - "会话失效", - "会话已失效", - "会话过期", - "会话已过期", - "请重新登录", - "token失效", - "token已失效" - ).any(normalized::contains) -} - -internal fun shouldRecoverCmbSession( - message: String, - recoveryAttempted: Boolean, - amount: String?, - password: String? -): Boolean = !recoveryAttempted && - !amount.isNullOrBlank() && - !password.isNullOrBlank() && - isCmbSessionExpiredMessage(message) - -/** - * Owns the hidden CMB WebView so the visible recharge screen can stay identical to the - * existing Agricultural Bank flow. The WebView is attached invisibly to keep its Vue/JS - * runtime alive, and every ready session is destroyed after three minutes. - */ -internal object CmbRechargeAutomationController { - private const val TAG = "CmbRechargeAutomation" - private const val PRELOAD_START_DELAY_MS = 1_000L - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private val _state = MutableStateFlow(CmbRechargeAutomationState()) - val state: StateFlow = _state.asStateFlow() - - private var webView: WebView? = null - private var hostRoot: ViewGroup? = null - private var nativeData: CmbRechargeNativeData? = null - private var readyAtElapsedMs = 0L - private var generation = 0 - private var pendingAmount: String? = null - private var pendingPassword: String? = null - private var submissionAmount: String? = null - private var submissionPassword: String? = null - private var submissionContext: Context? = null - private var officialPasswordPromptVisible = false - private var passwordDispatchInProgress = false - private var sessionRecoveryAttempted = false - private var userSubmissionActive = false - private var scheduledPreloadJob: Job? = null - private var sessionLoadJob: Job? = null - private var expiryJob: Job? = null - private var bootstrapTimeoutJob: Job? = null - private var paymentTimeoutJob: Job? = null - - fun schedulePreload(activity: Activity) { - if ( - AHUCache.getCardRechargeBank() != CardRechargeBank.CHINA_MERCHANTS_BANK || - !AHUCache.isLogin() || - hasFreshSession() || - sessionLoadJob?.isActive == true || - scheduledPreloadJob?.isActive == true - ) { - return - } - - scheduledPreloadJob = scope.launch { - delay(PRELOAD_START_DELAY_MS) - if ( - AHUCache.getCardRechargeBank() != CardRechargeBank.CHINA_MERCHANTS_BANK || - !AHUCache.isLogin() - ) { - return@launch - } - loadSession(activity, deferWebViewCreationUntilIdle = true) - } - } - - fun onBankSelected(context: Context, bank: CardRechargeBank) { - if (bank == CardRechargeBank.CHINA_MERCHANTS_BANK) { - (context as? Activity)?.let(::schedulePreload) - } - // Keep a fresh CMB session alive while the user compares banks. Its existing - // three-minute expiry remains authoritative and prevents repeated login traffic. - } - - fun submit(context: Context, amount: String) { - scope.launch { - pendingAmount = amount - pendingPassword = null - submissionAmount = amount - submissionPassword = null - submissionContext = context - officialPasswordPromptVisible = false - passwordDispatchInProgress = false - sessionRecoveryAttempted = false - userSubmissionActive = true - _state.value = CmbRechargeAutomationState( - CmbRechargePaymentPhase.PASSWORD_REQUIRED - ) - } - } - - fun submitPassword(password: String) { - if (!userSubmissionActive || pendingAmount == null && !officialPasswordPromptVisible) { - failUserSubmission("招商银行充值会话已失效,请重试") - return - } - - pendingPassword = password - submissionPassword = password - _state.value = CmbRechargeAutomationState(CmbRechargePaymentPhase.LOADING) - if (officialPasswordPromptVisible) { - dispatchPendingPassword() - return - } - - val context = submissionContext - if (context == null) { - failUserSubmission("招商银行充值会话已失效,请重试") - } else if (!hasFreshSession()) { - destroySession() - loadSession(context, deferWebViewCreationUntilIdle = false) - } else { - dispatchPendingRecharge() - } - } - - fun cancelPassword() { - paymentTimeoutJob?.cancel() - if (officialPasswordPromptVisible) webView?.cancelCmbRechargePassword() - pendingAmount = null - pendingPassword = null - submissionAmount = null - submissionPassword = null - submissionContext = null - officialPasswordPromptVisible = false - passwordDispatchInProgress = false - sessionRecoveryAttempted = false - userSubmissionActive = false - _state.value = CmbRechargeAutomationState() - } - - fun resetPaymentState() { - paymentTimeoutJob?.cancel() - pendingAmount = null - pendingPassword = null - submissionAmount = null - submissionPassword = null - submissionContext = null - officialPasswordPromptVisible = false - passwordDispatchInProgress = false - sessionRecoveryAttempted = false - userSubmissionActive = false - _state.value = CmbRechargeAutomationState() - } - - fun discard() { - scope.launch { - scheduledPreloadJob?.cancel() - sessionLoadJob?.cancel() - pendingAmount = null - pendingPassword = null - submissionAmount = null - submissionPassword = null - submissionContext = null - officialPasswordPromptVisible = false - passwordDispatchInProgress = false - sessionRecoveryAttempted = false - userSubmissionActive = false - destroySession() - _state.value = CmbRechargeAutomationState() - } - } - - private fun hasFreshSession(nowElapsedMs: Long = SystemClock.elapsedRealtime()): Boolean = - webView != null && - nativeData != null && - isCmbRechargeNativeEntryUrl(webView?.url) && - isCmbRechargeSessionFresh(readyAtElapsedMs, nowElapsedMs) - - private fun loadSession(context: Context, deferWebViewCreationUntilIdle: Boolean) { - if (sessionLoadJob?.isActive == true) return - val activity = context as? Activity - val applicationContext = context.applicationContext - sessionLoadJob = scope.launch { - val token = withContext(Dispatchers.IO) { TokenManager.awaitToken() } - if (token.isNullOrBlank()) { - handleSessionLoadFailure("校园卡登录凭证暂未就绪,请稍后重试") - return@launch - } - - if (deferWebViewCreationUntilIdle && pendingAmount == null) { - awaitMainThreadIdle() - } - if ( - pendingAmount == null && - AHUCache.getCardRechargeBank() != CardRechargeBank.CHINA_MERCHANTS_BANK - ) { - return@launch - } - - createAndLoadSession( - context = activity ?: applicationContext, - entryUrl = buildCmbRechargeEntryUrl(token) - ) - } - } - - private fun createAndLoadSession(context: Context, entryUrl: String) { - destroySession() - generation += 1 - val sessionGeneration = generation - lateinit var createdView: WebView - createdView = createCmbRechargeWebView( - context = context, - pageBackgroundColor = android.graphics.Color.TRANSPARENT, - pageStyleScript = { "" }, - onLoadingChanged = {}, - onProgressChanged = {}, - onSuccessPageChanged = {}, - onSuccessReturnBoundsChanged = { bounds -> - if ( - sessionGeneration == generation && - shouldConfirmCmbRechargeSuccess(createdView.url, bounds) - ) { - completeUserSubmission() - } - }, - onNativeDataChanged = { data -> - if (sessionGeneration != generation) return@createCmbRechargeWebView - nativeData = data - readyAtElapsedMs = SystemClock.elapsedRealtime() - bootstrapTimeoutJob?.cancel() - scheduleExpiry(sessionGeneration) - dispatchPendingRecharge() - }, - onPaymentUiStateChanged = { requiresPassword, pageError -> - if (sessionGeneration != generation || !userSubmissionActive) { - return@createCmbRechargeWebView - } - if (pageError.isNotBlank()) { - if (!recoverSubmissionAfterSessionExpiry(pageError)) { - failUserSubmission(pageError) - } - } else if (requiresPassword) { - officialPasswordPromptVisible = true - dispatchPendingPassword() - } - }, - onPageChanged = { url -> - Log.d(TAG, "CMB navigation: ${safeCmbPageLocation(url)}") - }, - onMainFrameError = { message -> - if (sessionGeneration == generation) handleSessionLoadFailure(message) - }, - onExternalLink = { - if (sessionGeneration == generation) { - handleSessionLoadFailure("招商银行充值需要打开未受支持的外部页面,请重试") - } - }, - onSubmitIntent = {} - ) - createdView.updateCmbRechargeWebViewVisibility(false) - attachHiddenWebView(context as? Activity, createdView) - syncYcardCookiesToWebView(createdView) - createdView.cmbRechargeState?.requestVersion = sessionGeneration - webView = createdView - createdView.loadUrl(entryUrl) - - bootstrapTimeoutJob = scope.launch { - delay(CMB_NATIVE_BOOTSTRAP_TIMEOUT_MS) - if (sessionGeneration == generation && nativeData == null) { - handleSessionLoadFailure("招商银行充值页面加载超时,请重试") - } - } - } - - private fun attachHiddenWebView(activity: Activity?, view: WebView) { - val root = activity?.findViewById(android.R.id.content) ?: return - hostRoot = root - root.addView( - view, - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - ) - } - - private fun dispatchPendingRecharge() { - val amount = pendingAmount ?: return - if (!canDispatchCmbRecharge(amount, pendingPassword, hasFreshSession())) { - if (pendingPassword == null) { - _state.value = CmbRechargeAutomationState( - CmbRechargePaymentPhase.PASSWORD_REQUIRED - ) - } - return - } - val data = nativeData ?: return - val currentView = webView ?: return - val paymentMethod = data.paymentMethods.firstOrNull() - if (paymentMethod == null) { - failUserSubmission("招商银行未找到可用的绑定银行卡") - return - } - - pendingAmount = null - _state.value = CmbRechargeAutomationState(CmbRechargePaymentPhase.LOADING) - startPaymentTimeout("招商银行充值请求超时,请重试") - currentView.submitCmbRecharge( - amount = amount, - paymentMethodIndex = paymentMethod.pageIndex, - onRejected = ::failUserSubmission - ) - } - - private fun dispatchPendingPassword() { - if (passwordDispatchInProgress) return - val password = pendingPassword - val currentView = webView - if ( - !canDispatchCmbPassword( - password = password, - dispatchInProgress = passwordDispatchInProgress, - hasWebView = currentView != null - ) - ) { - _state.value = CmbRechargeAutomationState( - CmbRechargePaymentPhase.PASSWORD_REQUIRED - ) - return - } - val dispatchPassword = password ?: return - val dispatchView = currentView ?: return - - passwordDispatchInProgress = true - pendingPassword = null - _state.value = CmbRechargeAutomationState(CmbRechargePaymentPhase.LOADING) - startPaymentTimeout("查询密码提交超时,请重试") - dispatchView.submitCmbRechargePassword( - password = dispatchPassword, - onRejected = ::failUserSubmission - ) - } - - private fun completeUserSubmission() { - if (!userSubmissionActive) return - paymentTimeoutJob?.cancel() - pendingAmount = null - pendingPassword = null - submissionAmount = null - submissionPassword = null - submissionContext = null - officialPasswordPromptVisible = false - passwordDispatchInProgress = false - sessionRecoveryAttempted = false - userSubmissionActive = false - _state.value = CmbRechargeAutomationState(CmbRechargePaymentPhase.SUCCESS) - scope.launch { - destroySession() - } - } - - private fun startPaymentTimeout(message: String) { - paymentTimeoutJob?.cancel() - paymentTimeoutJob = scope.launch { - delay(CMB_PASSWORD_DISPATCH_TIMEOUT_MS) - if (userSubmissionActive) failUserSubmission(message) - } - } - - private fun recoverSubmissionAfterSessionExpiry(message: String): Boolean { - val amount = submissionAmount - val password = submissionPassword - val context = submissionContext - if ( - context == null || - !shouldRecoverCmbSession( - message = message, - recoveryAttempted = sessionRecoveryAttempted, - amount = amount, - password = password - ) - ) { - return false - } - - sessionRecoveryAttempted = true - paymentTimeoutJob?.cancel() - pendingAmount = amount - pendingPassword = password - officialPasswordPromptVisible = false - passwordDispatchInProgress = false - _state.value = CmbRechargeAutomationState(CmbRechargePaymentPhase.LOADING) - destroySession() - loadSession(context, deferWebViewCreationUntilIdle = false) - return true - } - - private fun failUserSubmission(message: String) { - paymentTimeoutJob?.cancel() - pendingAmount = null - pendingPassword = null - submissionAmount = null - submissionPassword = null - submissionContext = null - officialPasswordPromptVisible = false - passwordDispatchInProgress = false - sessionRecoveryAttempted = false - userSubmissionActive = false - _state.value = CmbRechargeAutomationState( - phase = CmbRechargePaymentPhase.ERROR, - errorMessage = message - ) - scope.launch { destroySession() } - } - - private fun handleSessionLoadFailure(message: String) { - if (userSubmissionActive) { - failUserSubmission(message) - } else { - Log.w(TAG, message) - scope.launch { destroySession() } - } - } - - private fun scheduleExpiry(sessionGeneration: Int) { - expiryJob?.cancel() - expiryJob = scope.launch { - delay(CMB_RECHARGE_PRELOAD_VALIDITY_MS) - if (sessionGeneration == generation && !userSubmissionActive) { - Log.d(TAG, "Discarding expired three-minute CMB preload session") - destroySession() - } - } - } - - private fun destroySession() { - expiryJob?.cancel() - expiryJob = null - bootstrapTimeoutJob?.cancel() - bootstrapTimeoutJob = null - paymentTimeoutJob?.cancel() - paymentTimeoutJob = null - passwordDispatchInProgress = false - nativeData = null - readyAtElapsedMs = 0L - generation += 1 - - val currentView = webView - webView = null - currentView?.cmbRechargeState?.dispose() - (currentView?.parent as? ViewGroup)?.removeView(currentView) - hostRoot = null - currentView?.stopLoading() - currentView?.removeAllViews() - currentView?.destroy() - } - - private suspend fun awaitMainThreadIdle() { - suspendCancellableCoroutine { continuation -> - val queue = Looper.myQueue() - val idleHandler = MessageQueue.IdleHandler { - if (continuation.isActive) continuation.resume(Unit) - false - } - queue.addIdleHandler(idleHandler) - continuation.invokeOnCancellation { queue.removeIdleHandler(idleHandler) } - } - } -} - -@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 nativeData by remember { mutableStateOf(null) } - var showWebContent by remember { mutableStateOf(false) } - var forceWebContent by remember { mutableStateOf(false) } - var isSubmitting by remember { mutableStateOf(false) } - var successReturnBounds by remember { - mutableStateOf(null) - } - var errorMessage by remember { mutableStateOf(null) } - var queryPasswordRequired by remember { mutableStateOf(false) } - var nativeSuccess by remember { mutableStateOf(false) } - var isPasswordDispatching by remember { mutableStateOf(false) } - var allowWebContentReveal by remember { mutableStateOf(false) } - val isWebContentVisible = showWebContent || forceWebContent - - fun reloadEntry() { - progress = 0 - isLoading = true - errorMessage = null - isRechargeSuccessPage = false - nativeData = null - showWebContent = false - forceWebContent = false - isSubmitting = false - queryPasswordRequired = false - nativeSuccess = false - isPasswordDispatching = false - allowWebContentReveal = false - successReturnBounds = null - webView?.stopLoading() - loadRequestVersion += 1 - } - - val handleBack: () -> Unit = { - val currentWebView = webView - if (nativeSuccess) { - latestRechargeSuccessExit.value() - } else if (forceWebContent && isCmbRechargeNativeEntryUrl(currentWebView?.url)) { - forceWebContent = false - allowWebContentReveal = false - } else if (isWebContentVisible && currentWebView?.canGoBack() == true) { - currentWebView.goBack() - } else if (isWebContentVisible) { - reloadEntry() - } else { - onExit() - } - } - BackHandler(onBack = handleBack) - - LaunchedEffect(tokenRequestVersion) { - progress = 0 - isLoading = true - errorMessage = null - entryUrl = null - isRechargeSuccessPage = false - nativeData = null - showWebContent = false - forceWebContent = false - isSubmitting = false - queryPasswordRequired = false - nativeSuccess = false - isPasswordDispatching = false - allowWebContentReveal = 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 { - val currentView = webView - currentView?.stopLoading() - currentView?.cmbRechargeState?.dispose() - currentView?.destroy() - webView = null - } - } - - LaunchedEffect(pageStyleScript) { - webView?.let { currentView -> - applyCmbRechargePageStyle(currentView, currentView.url, pageStyleScript) - currentView.cmbRechargeState?.boundsLocator?.locate(currentView.url) - } - } - - LaunchedEffect(entryUrl, loadRequestVersion, nativeData, errorMessage, nativeSuccess) { - if ( - entryUrl == null || - nativeData != null || - errorMessage != null || - nativeSuccess - ) { - return@LaunchedEffect - } - delay(CMB_NATIVE_BOOTSTRAP_TIMEOUT_MS) - if (nativeData == null && errorMessage == null && !nativeSuccess) { - isLoading = false - errorMessage = "充值信息加载超时,请检查网络后重试" - } - } - - LaunchedEffect(isPasswordDispatching) { - if (!isPasswordDispatching) return@LaunchedEffect - delay(CMB_PASSWORD_DISPATCH_TIMEOUT_MS) - if (isPasswordDispatching) { - webView?.cancelCmbRechargePassword() - isPasswordDispatching = false - isSubmitting = false - allowWebContentReveal = false - errorMessage = "查询密码提交超时,请重试" - } - } - - LaunchedEffect(isRechargeSuccessPage, nativeSuccess) { - if (!isRechargeSuccessPage || nativeSuccess) return@LaunchedEffect - delay(CMB_SUCCESS_CONFIRMATION_TIMEOUT_MS) - if (isRechargeSuccessPage && !nativeSuccess) { - isSubmitting = false - allowWebContentReveal = true - showWebContent = true - forceWebContent = true - } - } - - val pageContentColor = colorScheme.onBackground - Scaffold( - modifier = Modifier.fillMaxSize(), - containerColor = pageBackgroundColor, - contentColor = pageContentColor, - topBar = { - AppPageHeader( - title = "招商银行充值", - onBack = handleBack, - modifier = Modifier - .zIndex(1f) - .statusBarsPadding() - ) - } - ) { contentPadding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(contentPadding) - .background(pageBackgroundColor) - .clipToBounds() - ) { - entryUrl?.let { url -> - val requestVersion = loadRequestVersion - AndroidView( - modifier = Modifier - .fillMaxSize() - .clipToBounds(), - factory = { viewContext -> - createCmbRechargeWebView( - context = viewContext, - pageBackgroundColor = pageBackgroundColor.toArgb(), - pageStyleScript = { latestPageStyleScript.value }, - onLoadingChanged = { isLoading = it }, - onProgressChanged = { progress = it }, - onSuccessPageChanged = { isSuccessPage -> - isRechargeSuccessPage = isSuccessPage - if (!isSuccessPage) { - nativeSuccess = false - successReturnBounds = null - } - }, - onSuccessReturnBoundsChanged = { bounds -> - successReturnBounds = bounds - if (shouldConfirmCmbRechargeSuccess(webView?.url, bounds)) { - nativeSuccess = true - errorMessage = null - isSubmitting = false - queryPasswordRequired = false - isPasswordDispatching = false - allowWebContentReveal = false - showWebContent = false - forceWebContent = false - } - }, - onNativeDataChanged = { pageData -> - nativeData = pageData - errorMessage = null - showWebContent = false - }, - onPaymentUiStateChanged = { requiresPassword, pageError -> - queryPasswordRequired = requiresPassword && !isPasswordDispatching - if (pageError.isNotBlank()) { - errorMessage = pageError - isSubmitting = false - isPasswordDispatching = false - allowWebContentReveal = false - } - }, - onPageChanged = { currentUrl -> - if (!isCmbRechargeNativeEntryUrl(currentUrl)) { - queryPasswordRequired = false - isPasswordDispatching = false - } - if ( - isCmbRechargeHiddenFlowUrl(currentUrl) || - isCmbRechargeSuccessUrl(currentUrl) - ) { - if (showWebContent) { - forceWebContent = false - allowWebContentReveal = false - } - showWebContent = false - } else if (isCmbRechargeInsecureEntryUrl(currentUrl)) { - allowWebContentReveal = true - showWebContent = true - forceWebContent = true - isSubmitting = false - } else if ( - shouldRevealCmbRechargeWebContent( - url = currentUrl, - revealAllowed = allowWebContentReveal - ) - ) { - showWebContent = true - isSubmitting = false - } else { - showWebContent = false - } - }, - onMainFrameError = { error -> - errorMessage = error - isRechargeSuccessPage = false - nativeSuccess = false - successReturnBounds = null - isSubmitting = false - queryPasswordRequired = false - isPasswordDispatching = false - showWebContent = false - forceWebContent = false - allowWebContentReveal = false - }, - onExternalLink = { externalUrl -> - openExternalLink(context, externalUrl) - }, - onSubmitIntent = { - behaviorReporter.organic(AppActionId.SUBMIT_CMB_CARD_RECHARGE) - } - ).also { created -> - created.updateCmbRechargeWebViewVisibility(isWebContentVisible) - syncYcardCookiesToWebView(created) - created.cmbRechargeState?.requestVersion = requestVersion - created.loadUrl(url) - webView = created - } - }, - update = { currentView -> - currentView.setBackgroundColor(pageBackgroundColor.toArgb()) - currentView.updateCmbRechargeWebViewVisibility(isWebContentVisible) - if (currentView.cmbRechargeState?.requestVersion != requestVersion) { - syncYcardCookiesToWebView(currentView) - currentView.cmbRechargeState?.requestVersion = requestVersion - currentView.loadUrl(url) - } - webView = currentView - } - ) - } - - if (isRechargeSuccessPage && isWebContentVisible) { - successReturnBounds?.let { bounds -> - CmbRechargeSuccessReturnOverlay( - bounds = bounds, - onClick = { - successReturnBounds = null - latestRechargeSuccessExit.value() - } - ) - } - } - - if (!isWebContentVisible && nativeSuccess) { - CmbRechargeNativeSuccessPanel(onDone = latestRechargeSuccessExit.value) - } else if (!isWebContentVisible) { - CmbRechargeNativePanel( - data = nativeData, - errorMessage = errorMessage, - isSubmitting = isSubmitting, - onRetry = { - if (entryUrl == null) { - tokenRequestVersion += 1 - } else { - reloadEntry() - } - }, - onManagePaymentMethods = { - errorMessage = null - allowWebContentReveal = true - forceWebContent = true - }, - onSubmit = { amount, paymentMethodIndex -> - behaviorReporter.organic(AppActionId.SUBMIT_CMB_CARD_RECHARGE) - errorMessage = null - isSubmitting = true - allowWebContentReveal = true - forceWebContent = false - webView?.submitCmbRecharge( - amount = amount, - paymentMethodIndex = paymentMethodIndex, - onRejected = { message -> - isSubmitting = false - allowWebContentReveal = false - forceWebContent = false - errorMessage = message - } - ) - } - ) - } - - if (queryPasswordRequired) { - CmbRechargeQueryPasswordDialog( - onCancel = { - queryPasswordRequired = false - isSubmitting = false - isPasswordDispatching = false - allowWebContentReveal = false - webView?.cancelCmbRechargePassword() - }, - onConfirm = { password -> - queryPasswordRequired = false - isPasswordDispatching = true - webView?.submitCmbRechargePassword( - password = password, - onRejected = { message -> - isSubmitting = false - isPasswordDispatching = false - allowWebContentReveal = false - errorMessage = message - } - ) - } - ) - } - - if (isWebContentVisible && isLoading) { - AppCircularProgressIndicator( - modifier = Modifier.align(Alignment.Center), - color = colorScheme.primary - ) - } - - if (isWebContentVisible && progress in 1..99) { - LinearProgressIndicator( - progress = { progress / 100f }, - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - ) - } - - if (isWebContentVisible) 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, - onNativeDataChanged: (CmbRechargeNativeData) -> Unit, - onPaymentUiStateChanged: (requiresPassword: Boolean, error: String) -> Unit, - onPageChanged: (String?) -> 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") - addJavascriptInterface( - CmbRechargeStateBridge(this, onNativeDataChanged, onPaymentUiStateChanged), - "AhuTongRechargeBridge" - ) - 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) - if (view != null && isCmbRechargeNativeEntryUrl(view.url)) { - view.evaluateJavascript(CMB_NATIVE_STATE_BRIDGE_SCRIPT, null) - } - } - } - } - - 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 - } - val upgradedCashierUrl = buildCmbHttpsCashierUrl(targetUri.toString()) - if (upgradedCashierUrl != null && upgradedCashierUrl != targetUri.toString()) { - view?.loadUrl(upgradedCashierUrl) - return true - } - return if (isCmbRechargeAllowedMainFrameUrl(targetUri.toString())) { - false - } else { - Log.w( - "CmbRechargeNavigation", - "Blocked main-frame navigation to ${safeCmbPageLocation(targetUri.toString())}" - ) - onExternalLink(targetUri.toString()) - true - } - } - - override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { - onLoadingChanged(true) - boundsLocator.clear() - updateSuccessPage(url) - onPageChanged(url) - super.onPageStarted(view, url, favicon) - } - - override fun onPageFinished(view: WebView?, url: String?) { - onLoadingChanged(false) - updateSuccessPage(url) - onPageChanged(url) - if (view != null) { - if (url?.contains("synjones-auth", ignoreCase = true) == false) { - // Do not retain the short-lived bootstrap token URL in WebView history. - view.clearHistory() - } - applyCmbRechargePageStyle(view, url, pageStyleScript()) - if (url?.let(Uri::parse)?.let(::isAuditedCmbSubmitPage) == true) { - view.evaluateJavascript(CMB_SUBMIT_OBSERVER_SCRIPT, null) - } - if (isCmbRechargeNativeEntryUrl(url)) { - view.evaluateJavascript(CMB_NATIVE_STATE_BRIDGE_SCRIPT, null) - view.evaluateJavascript(CMB_NATIVE_PAYMENT_UI_SCRIPT, null) - } - boundsLocator.locate(url) - } - super.onPageFinished(view, url) - } - - override fun onPageCommitVisible(view: WebView?, url: String?) { - onPageChanged(url) - if (view != null && isCmbRechargeNativeEntryUrl(url)) { - view.evaluateJavascript(CMB_NATIVE_STATE_BRIDGE_SCRIPT, null) - view.evaluateJavascript(CMB_NATIVE_PAYMENT_UI_SCRIPT, null) - } - super.onPageCommitVisible(view, url) - } - - override fun doUpdateVisitedHistory( - view: WebView?, - url: String?, - isReload: Boolean - ) { - onPageChanged(url) - if (view != null && isCmbRechargeNativeEntryUrl(url)) { - view.evaluateJavascript(CMB_NATIVE_STATE_BRIDGE_SCRIPT, null) - view.evaluateJavascript(CMB_NATIVE_PAYMENT_UI_SCRIPT, null) - } - 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) - } - - override fun onReceivedHttpError( - view: WebView?, - request: WebResourceRequest?, - errorResponse: WebResourceResponse? - ) { - if (request?.isForMainFrame == true) { - val statusCode = errorResponse?.statusCode - val pageLocation = safeCmbPageLocation(request.url?.toString()) - if (statusCode == 412 && isCmbLoginRedirectUrl(request.url?.toString())) { - Log.w( - "CmbRechargeHttp", - "CMB login redirect returned HTTP 412; awaiting page retry" - ) - onLoadingChanged(true) - super.onReceivedHttpError(view, request, errorResponse) - return - } - - val upgradedCashierUrl = if (statusCode == 412) { - buildCmbHttpsCashierUrl(request.url?.toString()) - } else { - null - } - if (upgradedCashierUrl != null) { - Log.w( - "CmbRechargeHttp", - "Upgrading CMB cashier navigation to HTTPS after HTTP 412" - ) - view?.loadUrl(upgradedCashierUrl) - super.onReceivedHttpError(view, request, errorResponse) - return - } - - Log.w("CmbRechargeHttp", "main-frame HTTP $statusCode at $pageLocation") - onLoadingChanged(false) - boundsLocator.clear() - onSuccessPageChanged(false) - onMainFrameError( - if (statusCode != null) { - "页面加载失败(HTTP $statusCode,$pageLocation),请稍后重试" - } else { - "页面加载失败,请稍后重试" - } - ) - } - super.onReceivedHttpError(view, request, errorResponse) - } - } - } -} - -private data class CmbRechargeBridgePayload( - val studentNumber: String = "", - val balance: Double = 0.0, - val paymentMethods: List = emptyList() -) - -private data class CmbRechargeBridgePaymentMethod( - val pageIndex: Int = -1, - val name: String = "" -) - -private class CmbRechargeStateBridge( - private val webView: WebView, - private val onNativeDataChanged: (CmbRechargeNativeData) -> Unit, - private val onPaymentUiStateChanged: (Boolean, String) -> Unit -) { - private val gson = Gson() - - @JavascriptInterface - fun onRechargeState(payload: String) { - webView.post { - if (!isCmbRechargeNativeEntryUrl(webView.url)) return@post - val parsed = runCatching { - gson.fromJson(payload, CmbRechargeBridgePayload::class.java) - }.getOrNull() ?: return@post - val methods = parsed.paymentMethods - .filter { it.pageIndex >= 0 && it.name.isNotBlank() } - .distinctBy { it.pageIndex } - .map { CmbRechargePaymentMethod(pageIndex = it.pageIndex, name = it.name) } - onNativeDataChanged( - CmbRechargeNativeData( - studentNumber = parsed.studentNumber, - balance = normalizeCmbRechargeBalance(parsed.balance), - paymentMethods = methods - ) - ) - } - } - - @JavascriptInterface - fun onPaymentUiState(payload: String) { - webView.post { - if (!isCmbRechargeNativeEntryUrl(webView.url)) return@post - val parsed = runCatching { - gson.fromJson(payload, CmbPaymentUiPayload::class.java) - }.getOrNull() ?: return@post - onPaymentUiStateChanged(parsed.passwordRequired, parsed.error.orEmpty()) - } - } -} - -private data class CmbPaymentUiPayload( - val passwordRequired: Boolean = false, - val error: String? = null -) - -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 fun WebView.submitCmbRecharge( - amount: String, - paymentMethodIndex: Int, - onRejected: (String) -> Unit -) { - val amountValue = amount.toDoubleOrNull() - if ( - !isCmbRechargeNativeEntryUrl(url) || - amountValue == null || - amountValue <= 0.0 || - amountValue > 1_000.0 || - paymentMethodIndex < 0 - ) { - onRejected("充值页面状态已变化,请重试") - return - } - val script = """ - (function(){ - var root = document.querySelector('#app'); - var queue = root && root.__vue__ ? [root.__vue__] : []; - var seen = []; - var component = null; - while (queue.length) { - var current = queue.shift(); - if (!current || seen.indexOf(current) >= 0) continue; - seen.push(current); - if (typeof current.rechargeOrders === 'function' && Array.isArray(current.payType)) { - component = current; - break; - } - var children = current[String.fromCharCode(36) + 'children']; - if (children) queue = queue.concat(children); - } - if (!component || !component.payType[$paymentMethodIndex]) return 'not-ready'; - component.tranAmt = $amountValue; - component.payTypeIndex = $paymentMethodIndex; - component.cardIndex = 0; - component.charge(); - return 'submitted'; - })(); - """.trimIndent() - evaluateJavascript(script) { result -> - if (result != "\"submitted\"") { - onRejected("充值页面尚未准备好,请稍后重试") - } - } -} - -private fun WebView.submitCmbRechargePassword( - password: String, - onRejected: (String) -> Unit -) { - if ( - !isCmbRechargeNativeEntryUrl(url) || - password.length != 6 || - !password.all(Char::isDigit) - ) { - onRejected("请输入 6 位校园卡查询密码") - return - } - val escapedPassword = password - .replace("\\", "\\\\") - .replace("'", "\\'") - val script = """ - (function(){ - function visible(node) { - if (!node) return false; - var style = window.getComputedStyle(node); - return style.display !== 'none' && - style.visibility !== 'hidden' && - node.getClientRects().length > 0; - } - var sheet = Array.from(document.querySelectorAll('.van-action-sheet')).find(visible); - if (!sheet || !(sheet.innerText || '').includes('查询密码')) return 'not-ready'; - var existingDots = Array.from( - sheet.querySelectorAll('.van-password-input__security i') - ).filter(visible).length; - if (existingDots !== 0) { - return 'password-not-empty'; - } - function currentSheet() { - return Array.from(document.querySelectorAll('.van-action-sheet')).find(visible); - } - function findCurrentKey(value) { - var current = currentSheet(); - return current && Array.from(current.querySelectorAll('.keyboard td')).find(function(node) { - return (node.innerText || '').trim() === value; - }); - } - function visiblePasswordDots() { - var current = currentSheet(); - return current - ? Array.from(current.querySelectorAll('.van-password-input__security i')) - .filter(visible).length - : 0; - } - function fail(message) { - window.AhuTongRechargeBridge.onPaymentUiState(JSON.stringify({ - passwordRequired: false, - error: message - })); - } - var password = '$escapedPassword'; - if (Array.from(password).some(function(value) { return !findCurrentKey(value); })) { - return 'key-not-found'; - } - if (!findCurrentKey('确认')) return 'confirm-not-found'; - function pressAt(index) { - if (index >= password.length) { - var confirm = findCurrentKey('确认'); - if (confirm && visiblePasswordDots() === password.length) { - confirm.click(); - } else { - fail('查询密码键盘状态异常,请重试'); - } - return; - } - var key = findCurrentKey(password[index]); - if (!key) { - fail('查询密码键盘已变化,请重试'); - return; - } - key.click(); - var attempts = 0; - function waitForDot() { - if (visiblePasswordDots() >= index + 1) { - window.setTimeout(function() { pressAt(index + 1); }, 120); - } else if (attempts++ < 15) { - window.setTimeout(waitForDot, 50); - } else { - fail('查询密码键盘响应超时,请重试'); - } - } - window.setTimeout(waitForDot, 50); - } - pressAt(0); - return 'scheduled'; - })(); - """.trimIndent() - evaluateJavascript(script) { result -> - if (result != "\"scheduled\"") { - onRejected("查询密码键盘尚未准备好,请重试") - } - } -} - -private fun WebView.cancelCmbRechargePassword() { - if (!isCmbRechargeNativeEntryUrl(url)) return - evaluateJavascript( - """ - (function(){ - var sheet = Array.from(document.querySelectorAll('.van-action-sheet')) - .find(function(node){ return (node.innerText || '').includes('查询密码'); }); - var cancel = sheet && sheet.querySelector('.van-action-sheet__cancel'); - if (cancel) { - cancel.click(); - } else { - var overlays = Array.from(document.querySelectorAll('.van-overlay')); - var overlay = overlays.find(function(node) { - return window.getComputedStyle(node).display !== 'none'; - }); - if (overlay) overlay.click(); - } - })(); - """.trimIndent(), - null - ) -} - -private class CmbRechargeWebViewState( - val boundsLocator: CmbRechargeBoundsLocator, - var requestVersion: Int = -1 -) { - fun dispose() { - boundsLocator.dispose() - } -} - -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" && - uri.port in setOf(-1, 443) && - host == "epay92.ahu.edu.cn" && - path == "/cashier-mobile/chargeresult" -} - -internal fun shouldConfirmCmbRechargeSuccess( - url: String?, - verifiedReturnBounds: CmbRechargeNormalizedBounds? -): Boolean = verifiedReturnBounds != null && isCmbRechargeSuccessUrl(url) - -internal fun isCmbRechargeStyleTarget(url: String?): Boolean { - if (url.isNullOrBlank()) return false - val uri = runCatching { URI(url) }.getOrNull() ?: return false - if (uri.scheme.orEmpty().lowercase() != "https" || uri.port !in setOf(-1, 443)) 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 - } -} - -internal fun isCmbRechargeNativeEntryUrl(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" && - uri.port in setOf(-1, 443) && - host == "epay92.ahu.edu.cn" && - path == "/cashier-mobile/charge" -} - -internal fun isCmbRechargeInsecureEntryUrl(url: String?): Boolean { - if (url.isNullOrBlank()) return false - val uri = runCatching { URI(url) }.getOrNull() ?: return false - return uri.scheme.orEmpty().equals("http", ignoreCase = true) && - uri.port in setOf(-1, 80) && - uri.host.orEmpty().equals("epay92.ahu.edu.cn", ignoreCase = true) && - uri.path.orEmpty().trimEnd('/').equals( - "/cashier-mobile/charge", - ignoreCase = true - ) -} - -internal fun isCmbRechargeHiddenFlowUrl(url: String?): Boolean { - if (isCmbRechargeNativeEntryUrl(url)) return true - 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 == "ycard.ahu.edu.cn" && - uri.port in setOf(-1, 443) && - path == "/berserker-base/redirect" -} - -internal fun shouldRevealCmbRechargeWebContent( - url: String?, - revealAllowed: Boolean -): Boolean = revealAllowed && - !url.isNullOrBlank() && - !isCmbRechargeHiddenFlowUrl(url) && - !isCmbRechargeSuccessUrl(url) - -private fun WebView.updateCmbRechargeWebViewVisibility(isVisible: Boolean) { - visibility = if (isVisible) View.VISIBLE else View.INVISIBLE - isEnabled = isVisible - importantForAccessibility = if (isVisible) { - View.IMPORTANT_FOR_ACCESSIBILITY_AUTO - } else { - View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS - } -} - -internal fun normalizeCmbRechargeBalance(balanceInCents: Double): Double = - if (balanceInCents.isFinite()) balanceInCents / 100.0 else 0.0 - -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() -} - -internal fun isCmbLoginRedirectUrl(url: String?): Boolean { - val uri = url?.let { runCatching { URI(it) }.getOrNull() } ?: return false - return uri.scheme.equals("https", ignoreCase = true) && - uri.host.equals("epay92.ahu.edu.cn", ignoreCase = true) && - uri.port in setOf(-1, 443) && - uri.path.orEmpty().trimEnd('/').equals( - "/member/login/redirect", - ignoreCase = true - ) -} - -internal fun buildCmbHttpsCashierUrl(url: String?): String? { - val uri = url?.let { runCatching { URI(it) }.getOrNull() } ?: return null - val scheme = uri.scheme.orEmpty().lowercase() - val trustedPort = scheme == "http" && uri.port in setOf(-1, 80) - if ( - !trustedPort || - !uri.host.equals("epay92.ahu.edu.cn", ignoreCase = true) || - !uri.path.orEmpty().trimEnd('/').equals( - "/cashier-mobile/cashier", - ignoreCase = true - ) - ) { - return null - } - - return URI( - "https", - uri.userInfo, - uri.host, - -1, - uri.path, - uri.query, - uri.fragment - ).toString() -} - -private fun safeCmbPageLocation(url: String?): String { - val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return "未知页面" - val scheme = uri.scheme.orEmpty().lowercase() - val host = uri.host.orEmpty().lowercase() - val path = uri.encodedPath.orEmpty().ifBlank { "/" } - return "$scheme://$host$path" -} - -internal fun isCmbRechargeAllowedMainFrameUrl(url: String?): Boolean { - val uri = url?.let { runCatching { URI(it) }.getOrNull() } ?: return false - if (uri.scheme.orEmpty().lowercase() != "https" || uri.port !in setOf(-1, 443)) return false - val host = uri.host.orEmpty().lowercase() - val path = uri.path.orEmpty() - return when (host) { - "ycard.ahu.edu.cn" -> - path == "/berserker-base/redirect" || - path == "/charge-app" || - path.startsWith("/charge-app/") - "epay92.ahu.edu.cn" -> - path == "/member/login/redirect" || - path == "/cashier-mobile" || - path.startsWith("/cashier-mobile/") - else -> false - } -} - -private fun isAuditedCmbSubmitPage(url: Uri): Boolean = - isCmbRechargeAllowedMainFrameUrl(url.toString()) && - (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/CmbRechargeNativePanel.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbRechargeNativePanel.kt deleted file mode 100644 index 7d27bca0..00000000 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbRechargeNativePanel.kt +++ /dev/null @@ -1,474 +0,0 @@ -package com.ahu.ahutong.ui.screen.main - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.CheckCircle -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import com.ahu.ahutong.ui.components.AppCircularProgressIndicator -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -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.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.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalFocusManager -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.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.ahu.ahutong.ui.component.SecurePaymentPasswordDialog -import com.ahu.ahutong.ui.components.AppSelectField -import com.ahu.ahutong.ui.components.AppSelectOption -import java.util.Locale - -internal data class CmbRechargeNativeData( - val studentNumber: String, - val balance: Double, - val paymentMethods: List -) - -internal data class CmbRechargePaymentMethod( - val pageIndex: Int, - val name: String -) - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun CmbRechargeNativePanel( - data: CmbRechargeNativeData?, - errorMessage: String?, - isSubmitting: Boolean, - onRetry: () -> Unit, - onManagePaymentMethods: () -> Unit, - onSubmit: (amount: String, paymentMethodIndex: Int) -> Unit -) { - var amount by remember { mutableStateOf("") } - var selectedPaymentMethodIndex by remember { mutableIntStateOf(-1) } - val focusManager = LocalFocusManager.current - - LaunchedEffect(data?.paymentMethods) { - val methods = data?.paymentMethods.orEmpty() - if (methods.none { it.pageIndex == selectedPaymentMethodIndex }) { - selectedPaymentMethodIndex = methods.firstOrNull()?.pageIndex ?: -1 - } - } - - val amountValue = amount.toDoubleOrNull() - val amountError = when { - amount.isBlank() -> null - amountValue == null || amountValue <= 0.0 -> "请输入有效的充值金额" - amountValue > CMB_RECHARGE_MAX_AMOUNT -> "单次充值金额不能超过 1000 元" - else -> null - } - val selectedMethod = data?.paymentMethods - ?.firstOrNull { it.pageIndex == selectedPaymentMethodIndex } - val canSubmit = data != null && - selectedMethod != null && - amountValue != null && - amountValue > 0.0 && - amountValue <= CMB_RECHARGE_MAX_AMOUNT && - !isSubmitting - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - ) { - when { - data == null && errorMessage != null -> NativeRechargeLoadState( - title = "充值信息加载失败", - message = errorMessage, - onRetry = onRetry - ) - - data == null -> NativeRechargeLoadState( - title = "正在加载充值信息", - message = "正在安全连接校园卡充值服务,请稍候。" - ) - - else -> { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentPadding = PaddingValues( - start = 16.dp, - top = 16.dp, - end = 16.dp, - bottom = 12.dp - ), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - if (errorMessage != null) { - item { - NativeRechargeMessageCard( - title = "本次充值未完成", - message = errorMessage, - actionText = "重新加载", - onAction = onRetry - ) - } - } - - item { NativeRechargeAccountCard(data = data) } - - item { - NativeRechargeSection(title = "充值金额") { - OutlinedTextField( - value = amount, - onValueChange = { value -> - if (value.matches(Regex("^\\d{0,4}(\\.\\d{0,2})?$"))) { - amount = value - } - }, - modifier = Modifier.fillMaxWidth(), - label = { Text("充值金额") }, - prefix = { Text("¥ ") }, - placeholder = { Text("请输入金额") }, - supportingText = amountError?.let { message -> { Text(message) } }, - isError = amountError != null, - singleLine = true, - enabled = !isSubmitting, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Decimal, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { focusManager.clearFocus() } - ) - ) - - CMB_RECHARGE_PRESETS.chunked(2).forEach { presets -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - presets.forEach { preset -> - OutlinedButton( - onClick = { - amount = preset - focusManager.clearFocus() - }, - modifier = Modifier - .weight(1f) - .height(48.dp), - enabled = !isSubmitting, - contentPadding = PaddingValues(horizontal = 8.dp) - ) { - Text("¥$preset") - } - } - } - } - } - } - - item { - NativeRechargeSection(title = "支付方式") { - if (data.paymentMethods.isEmpty()) { - Text( - text = "尚未绑定可用的免密支付方式,请先前往学校支付页面完成绑定。", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - TextButton( - onClick = onManagePaymentMethods, - enabled = !isSubmitting - ) { - Text("管理免密支付方式") - } - } else { - AppSelectField( - label = "扣款方式", - selected = selectedMethod?.pageIndex, - options = data.paymentMethods.map { method -> - AppSelectOption(method.pageIndex, method.name) - }, - onSelected = { selectedPaymentMethodIndex = it }, - enabled = !isSubmitting - ) - TextButton( - onClick = onManagePaymentMethods, - enabled = !isSubmitting - ) { - Text("管理支付方式") - } - } - } - } - - item { - Text( - text = "充值金额将先进入过渡余额,刷卡后转入校园卡。银行卡授权与验证码只在官方页面完成;如需校园卡查询密码,本页会将其安全转交当前校方充值页面。", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - } - } - - Surface( - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 3.dp - ) { - Button( - onClick = { - focusManager.clearFocus() - onSubmit(amount, selectedPaymentMethodIndex) - }, - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .imePadding() - .padding(horizontal = 16.dp, vertical = 12.dp) - .height(56.dp), - enabled = canSubmit - ) { - if (isSubmitting) { - AppCircularProgressIndicator( - size = 24.dp, - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp - ) - } else { - Text("确认充值") - } - } - } - } - } - } -} - -@Composable -private fun ColumnScope.NativeRechargeLoadState( - title: String, - message: String, - onRetry: (() -> Unit)? = null -) { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .padding(24.dp), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - if (onRetry == null) { - AppCircularProgressIndicator(size = 36.dp) - } - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center - ) - Text( - text = message, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center - ) - onRetry?.let { retry -> - Button(onClick = retry) { Text("重试") } - } - } - } -} - -@Composable -private fun NativeRechargeAccountCard(data: CmbRechargeNativeData) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(24.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 1.dp - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "校园卡当前余额", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.labelLarge - ) - Text( - text = String.format(Locale.CHINA, "¥ %.2f", data.balance), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.headlineMedium - ) - NativeRechargeInfoRow( - label = "学工号", - value = data.studentNumber.ifBlank { "未获取到" } - ) - } - } -} - -@Composable -private fun NativeRechargeSection( - title: String, - content: @Composable ColumnScope.() -> Unit -) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text(title, style = MaterialTheme.typography.titleMedium) - content() - } - } -} - -@Composable -private fun NativeRechargeInfoRow(label: String, value: String) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant) - Text( - text = value, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.End, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } -} - -@Composable -private fun NativeRechargeMessageCard( - title: String, - message: String, - actionText: String, - onAction: () -> Unit -) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.errorContainer - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = title, - color = MaterialTheme.colorScheme.onErrorContainer, - style = MaterialTheme.typography.titleMedium - ) - Text(message, color = MaterialTheme.colorScheme.onErrorContainer) - TextButton(onClick = onAction) { Text(actionText) } - } - } -} - -private const val CMB_RECHARGE_MAX_AMOUNT = 1_000.0 -private val CMB_RECHARGE_PRESETS = listOf("50", "100", "200", "500") - -@Composable -internal fun CmbRechargeQueryPasswordDialog( - onCancel: () -> Unit, - onConfirm: (String) -> Unit -) { - var password by remember { mutableStateOf("") } - - SecurePaymentPasswordDialog( - password = password, - onPasswordChange = { password = it }, - title = "输入校园卡查询密码", - onDismissRequest = onCancel, - onConfirm = onConfirm - ) -} - -@Composable -internal fun CmbRechargeNativeSuccessPanel( - onDone: () -> Unit -) { - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .navigationBarsPadding() - .padding(24.dp) - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Icon( - imageVector = Icons.Rounded.CheckCircle, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - text = "充值成功", - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center - ) - Text( - text = "订单已由招商银行免密支付完成,余额将在刷卡后转入校园卡。", - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } - } - Button( - onClick = onDone, - modifier = Modifier - .fillMaxWidth() - .height(52.dp) - ) { - Text("返回校园卡") - } - } -} 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 f3f98bff..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 @@ -28,6 +28,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.ahu.ahutong.data.crawler.PayState +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 @@ -56,6 +57,7 @@ fun ElectricityDeposit( ) { val behaviorReporter = rememberBehaviorActionReporter() 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() @@ -74,6 +76,9 @@ fun ElectricityDeposit( 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) } + } val campusOptions = remember(campusList) { campusList.map { AppSelectOption(it, it.name) } } @@ -94,8 +99,9 @@ fun ElectricityDeposit( } } - val canPay = selectedCampus != null && selectedBuilding != null && selectedFloor != null && - selectedRoom != null && amount.toDoubleOrNull()?.let { it > 0.0 } == true && + val canPay = (!selectedController.requiresCampus || selectedCampus != null) && + selectedBuilding != null && selectedFloor != null && selectedRoom != null && + amount.toDoubleOrNull()?.let { it > 0.0 } == true && !isLoading && payState is PayState.Idle AppScrollablePageLayout( @@ -150,7 +156,7 @@ fun ElectricityDeposit( val loadingSelector = when { !isLoading -> null - selectedCampus == null -> ElectricitySelectorLevel.Campus + selectedController.requiresCampus && selectedCampus == null -> ElectricitySelectorLevel.Campus selectedBuilding == null -> ElectricitySelectorLevel.Building selectedFloor == null -> ElectricitySelectorLevel.Floor selectedRoom == null -> ElectricitySelectorLevel.Room @@ -160,24 +166,35 @@ fun ElectricityDeposit( modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - ElectricitySelectorField( - label = "校区", - selected = selectedCampus, - options = campusOptions, - onSelected = viewModel::onCampusSelected, - modifier = Modifier, - placeholder = "请选择校区", + AppSelectField( + label = "电控入口", + selected = selectedController, + options = controllerOptions, + onSelected = viewModel::onControllerSelected, + modifier = Modifier.fillMaxWidth(), enabled = !isLoading, - loading = loadingSelector == ElectricitySelectorLevel.Campus + 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 = "请先选择校区", - enabled = selectedCampus != null && !isLoading, + placeholder = if (selectedController.requiresCampus) "请先选择校区" else "请选择楼栋", + enabled = (!selectedController.requiresCampus || selectedCampus != null) && !isLoading, loading = loadingSelector == ElectricitySelectorLevel.Building ) ElectricitySelectorField( @@ -203,94 +220,94 @@ fun ElectricityDeposit( } roomInfo?.takeIf(String::isNotBlank)?.let { info -> - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .appLiquidGlassSurface( - shape = AppComponentTokens.CardShape, - fallbackColor = MaterialTheme.colorScheme.surfaceContainer, - level = LiquidGlassSurfaceLevel.Panel - ) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "房间信息", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - Text( - text = info.replace(",", "\n"), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyLarge - ) - } - } - - Column( + Column( modifier = Modifier .padding(horizontal = 16.dp) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) + .fillMaxWidth() + .appLiquidGlassSurface( + shape = AppComponentTokens.CardShape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainer, + level = LiquidGlassSurfaceLevel.Panel + ) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - 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(), - enabled = !isLoading, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Decimal, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + Text( + text = info.replace(",", "\n"), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge ) } + } 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) + 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 } - 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 }, + }, + label = "金额(元)", + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading, + 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(), - enabled = canPay + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically ) { - Text(if (payState is PayState.InProgress) "正在支付" else "确认缴费") + 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 + ) { + Text(if (payState is PayState.InProgress) "正在支付" else "确认缴费") + } } } 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/ElectricityDepositViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/state/ElectricityDepositViewModel.kt index d176e3fb..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 @@ -23,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 @@ -149,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 @@ -200,13 +204,19 @@ class ElectricityDepositViewModel @Inject constructor( .take(MAX_ROOM_HISTORY) _historyOptions.value = history val lastSelection = AHUCache.getRoomSelection() - ?.takeIf(::isCompleteSelection) - ?: history.firstOrNull { isCompleteSelection(it.selection) }?.selection + ?.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 { - fetchCampuses() + fetchInitialOptions() } viewModelScope.launch { _presetCandidates.value = behaviorRuntime.rankLocalPresets(SemanticDomain.ELECTRICITY) @@ -219,6 +229,9 @@ class ElectricityDepositViewModel @Inject constructor( _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 @@ -244,26 +257,27 @@ class ElectricityDepositViewModel @Inject constructor( } ?: throw Exception(roomDetails.msg ?: "加载房间信息失败") _isLoading.value = false - val (campuses, buildings, floors, rooms) = coroutineScope { - val campusesRequest = async { getCampus() } - val buildingsRequest = async { getBuildings() } + coroutineScope { + val initialOptionsRequest = async { getInitialOptions() } + val buildingsRequest = if (controller.requiresCampus) { + async { getBuildings() } + } else { + null + } val floorsRequest = async { getFloor() } val roomsRequest = async { getRoom() } - listOf( - campusesRequest.await(), - buildingsRequest.await(), - floorsRequest.await(), - roomsRequest.await() - ) + + 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 } } - @Suppress("UNCHECKED_CAST") - (campuses.data as? List)?.let { _campusList.value = it } - @Suppress("UNCHECKED_CAST") - (buildings.data as? List)?.let { _buildingsList.value = it } - @Suppress("UNCHECKED_CAST") - (floors.data as? List)?.let { _floorsList.value = it } - @Suppress("UNCHECKED_CAST") - (rooms.data as? List)?.let { _roomsList.value = it } if (commitPresetOnRoomRequest) recordRoomPreset() Log.d("ElectricityDepositViewModel", "从缓存恢复选择成功") } catch (e: CancellationException) { @@ -290,6 +304,23 @@ class ElectricityDepositViewModel @Inject constructor( 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 @@ -299,6 +330,7 @@ class ElectricityDepositViewModel @Inject constructor( _selectedFloor.value = null _roomsList.value = emptyList() _selectedRoom.value = null + _fullRoomDetails.value = null _roomInfo.value = null fetchBuildings() } @@ -310,6 +342,7 @@ class ElectricityDepositViewModel @Inject constructor( _selectedFloor.value = null _roomsList.value = emptyList() _selectedRoom.value = null + _fullRoomDetails.value = null _roomInfo.value = null fetchFloor() } @@ -319,6 +352,7 @@ class ElectricityDepositViewModel @Inject constructor( _selectedFloor.value = floor _roomsList.value = emptyList() _selectedRoom.value = null + _fullRoomDetails.value = null _roomInfo.value = null fetchRoom() } @@ -326,37 +360,43 @@ class ElectricityDepositViewModel @Inject constructor( fun onRoomSelected(room: CampusDataItem) { selectionLoadJob?.cancel() _selectedRoom.value = room + _fullRoomDetails.value = null _roomInfo.value = null fetchRoomInfo() } fun retry() { when { - _selectedCampus.value == null -> fetchCampuses() - _selectedBuilding.value == null -> fetchBuildings() + _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 fetchCampuses() { + 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) { val items = response.data!! - _campusList.value = items - if (_selectedCampus.value == null) { - items.firstOrNull()?.let { first -> - _selectedCampus.value = first - fetchBuildings() + 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}" @@ -368,22 +408,22 @@ class ElectricityDepositViewModel @Inject constructor( } } - 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.authorizedCall { getFeeItemThirdData(formBody) } - Log.d("ElectricityDepositViewModel", "getCampus响应码: ${res.code()}") + 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) @@ -391,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 } @@ -446,7 +486,7 @@ class ElectricityDepositViewModel @Inject constructor( } val formBody = FormBody.Builder() - .add("feeitemid", "488") + .add("feeitemid", _selectedController.value.feeItemId) .add("type", "select") .add("level", "1") .add("campus", selectedCampusValue) @@ -511,7 +551,9 @@ class ElectricityDepositViewModel @Inject constructor( 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 @@ -522,11 +564,12 @@ 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() @@ -589,12 +632,14 @@ class ElectricityDepositViewModel @Inject constructor( 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 @@ -605,11 +650,12 @@ 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() @@ -685,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() @@ -697,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 } @@ -747,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 @@ -763,17 +825,19 @@ 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) @@ -833,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", @@ -1011,7 +1075,8 @@ class ElectricityDepositViewModel @Inject constructor( campus = _selectedCampus.value, building = _selectedBuilding.value, floor = _selectedFloor.value, - room = _selectedRoom.value + room = _selectedRoom.value, + controller = _selectedController.value ) persistCurrentSelection( selection = roomSelectionInfo, @@ -1078,7 +1143,8 @@ class ElectricityDepositViewModel @Inject constructor( } private fun isCompleteSelection(selection: RoomSelectionInfo): Boolean { - return selection.campus != null && + val controller = selection.controller ?: ElectricityController.C + return (!controller.requiresCampus || selection.campus != null) && selection.building != null && selection.floor != null && selection.room != null @@ -1089,7 +1155,8 @@ class ElectricityDepositViewModel @Inject constructor( campus = _selectedCampus.value, building = _selectedBuilding.value, floor = _selectedFloor.value, - room = _selectedRoom.value + room = _selectedRoom.value, + controller = _selectedController.value ), confirmedByPayment: Boolean = false ) { @@ -1097,10 +1164,12 @@ class ElectricityDepositViewModel @Inject constructor( saveRoomSelection(selection) if (!confirmedByPayment) return - val label = normalizeLabel( + val roomLabel = normalizeLabel( _fullRoomDetails.value?.data?.roomName ?: selection.room?.name.orEmpty() ) - if (label.isBlank()) return + if (roomLabel.isBlank()) return + val controller = selection.controller ?: ElectricityController.C + val label = "${controller.displayName} · $roomLabel" val item = ElectricityDepositHistoryItem( selection = selection, @@ -1118,6 +1187,7 @@ class ElectricityDepositViewModel @Inject constructor( private fun selectionKey(selection: RoomSelectionInfo): String { return listOf( + (selection.controller ?: ElectricityController.C).name, selection.campus?.value, selection.building?.value, selection.floor?.value, 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 bed23aef..00000000 --- a/app/src/test/java/com/ahu/ahutong/ui/screen/main/CmbRechargePageStyleTest.kt +++ /dev/null @@ -1,514 +0,0 @@ -package com.ahu.ahutong.ui.screen.main - -import kotlin.test.Test -import kotlin.test.assertContains -import kotlin.test.assertEquals -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" - ) - ) - assertFalse(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/")) - assertFalse( - isCmbRechargeStyleTarget( - "https://epay92.ahu.edu.cn:444/cashier-mobile/charge" - ) - ) - } - - @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( - "http://epay92.ahu.edu.cn/cashier-mobile/chargeResult" - ) - ) - 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:8080/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 nativeEntryUrlIsStrictlyScoped() { - assertFalse( - isCmbRechargeNativeEntryUrl( - "http://epay92.ahu.edu.cn/cashier-mobile/charge?disable=1" - ) - ) - assertTrue( - isCmbRechargeInsecureEntryUrl( - "http://epay92.ahu.edu.cn/cashier-mobile/charge?disable=1" - ) - ) - assertFalse( - isCmbRechargeInsecureEntryUrl( - "http://epay92.ahu.edu.cn.evil.example/cashier-mobile/charge" - ) - ) - assertTrue( - isCmbRechargeNativeEntryUrl( - "https://epay92.ahu.edu.cn/cashier-mobile/charge/" - ) - ) - assertFalse( - isCmbRechargeNativeEntryUrl( - "https://epay92.ahu.edu.cn.evil.example/cashier-mobile/charge" - ) - ) - assertFalse( - isCmbRechargeNativeEntryUrl( - "https://epay92.ahu.edu.cn/cashier-mobile/chargeResult" - ) - ) - assertFalse( - isCmbRechargeNativeEntryUrl( - "https://epay92.ahu.edu.cn:444/cashier-mobile/charge" - ) - ) - } - - @Test - fun hiddenFlowUrlIncludesOnlyTheTrustedBootstrapAndNativeEntry() { - assertTrue( - isCmbRechargeHiddenFlowUrl( - "https://ycard.ahu.edu.cn/berserker-base/redirect?appId=253" - ) - ) - assertTrue( - isCmbRechargeHiddenFlowUrl( - "https://epay92.ahu.edu.cn/cashier-mobile/charge?disable=1" - ) - ) - assertFalse( - isCmbRechargeHiddenFlowUrl( - "https://ycard.ahu.edu.cn/berserker-base/redirect/other" - ) - ) - assertFalse( - isCmbRechargeHiddenFlowUrl( - "https://ycard.ahu.edu.cn.evil.example/berserker-base/redirect" - ) - ) - } - - @Test - fun webContentIsRevealedOnlyAfterAnExplicitNativeAction() { - val bootstrapUrl = - "https://ycard.ahu.edu.cn/berserker-base/redirect?appId=253" - val nativeEntryUrl = - "https://epay92.ahu.edu.cn/cashier-mobile/charge?disable=1" - val officialPaymentUrl = - "https://epay92.ahu.edu.cn/cashier-mobile/pay" - val successUrl = - "https://epay92.ahu.edu.cn/cashier-mobile/chargeResult" - - assertFalse(shouldRevealCmbRechargeWebContent(null, revealAllowed = true)) - assertFalse(shouldRevealCmbRechargeWebContent(bootstrapUrl, revealAllowed = true)) - assertFalse(shouldRevealCmbRechargeWebContent(nativeEntryUrl, revealAllowed = true)) - assertFalse(shouldRevealCmbRechargeWebContent(officialPaymentUrl, revealAllowed = false)) - assertTrue(shouldRevealCmbRechargeWebContent(officialPaymentUrl, revealAllowed = true)) - assertFalse(shouldRevealCmbRechargeWebContent(successUrl, revealAllowed = true)) - } - - @Test - fun nativeBalanceConvertsServerCentsToYuan() { - assertEquals(7.79, normalizeCmbRechargeBalance(779.0), 0.0001) - assertEquals(0.0, normalizeCmbRechargeBalance(Double.NaN), 0.0001) - } - - @Test - fun preloadedSessionExpiresAtThreeMinutes() { - val readyAt = 10_000L - - assertTrue(isCmbRechargeSessionFresh(readyAt, readyAt)) - assertTrue( - isCmbRechargeSessionFresh( - readyAt, - readyAt + CMB_RECHARGE_PRELOAD_VALIDITY_MS - 1L - ) - ) - assertFalse( - isCmbRechargeSessionFresh( - readyAt, - readyAt + CMB_RECHARGE_PRELOAD_VALIDITY_MS - ) - ) - assertFalse(isCmbRechargeSessionFresh(0L, readyAt)) - assertFalse(isCmbRechargeSessionFresh(readyAt, readyAt - 1L)) - } - - @Test - fun rechargeCannotDispatchBeforePasswordIsProvided() { - assertFalse( - canDispatchCmbRecharge( - amount = "100", - password = null, - hasFreshSession = true - ) - ) - assertFalse( - canDispatchCmbRecharge( - amount = "100", - password = "", - hasFreshSession = true - ) - ) - assertFalse( - canDispatchCmbRecharge( - amount = "100", - password = "123456", - hasFreshSession = false - ) - ) - assertTrue( - canDispatchCmbRecharge( - amount = "100", - password = "123456", - hasFreshSession = true - ) - ) - - assertTrue( - canDispatchCmbPassword( - password = "123456", - dispatchInProgress = false, - hasWebView = true - ) - ) - assertFalse( - canDispatchCmbPassword( - password = null, - dispatchInProgress = false, - hasWebView = true - ) - ) - assertFalse( - canDispatchCmbPassword( - password = "123456", - dispatchInProgress = true, - hasWebView = true - ) - ) - } - - @Test - fun expiredSessionRecoveryRequiresCompleteSubmissionAndRunsOnlyOnce() { - assertTrue(isCmbSessionExpiredMessage("登录已失效,请重新登录")) - assertTrue(isCmbSessionExpiredMessage("当前会话已过期")) - assertFalse(isCmbSessionExpiredMessage("查询密码错误")) - - assertTrue( - shouldRecoverCmbSession( - message = "登录失效", - recoveryAttempted = false, - amount = "100", - password = "123456" - ) - ) - assertFalse( - shouldRecoverCmbSession( - message = "登录失效", - recoveryAttempted = true, - amount = "100", - password = "123456" - ) - ) - assertFalse( - shouldRecoverCmbSession( - message = "登录失效", - recoveryAttempted = false, - amount = "100", - password = null - ) - ) - assertFalse( - shouldRecoverCmbSession( - message = "查询密码错误", - recoveryAttempted = false, - amount = "100", - password = "123456" - ) - ) - } - - @Test - fun loginRedirectAndHttpsUpgradeAreStrictlyScoped() { - assertTrue( - isCmbLoginRedirectUrl( - "https://epay92.ahu.edu.cn/member/login/redirect?ticket=hidden" - ) - ) - assertFalse( - isCmbLoginRedirectUrl( - "https://epay92.ahu.edu.cn/cashier-mobile/charge" - ) - ) - assertFalse( - isCmbLoginRedirectUrl( - "https://epay92.ahu.edu.cn.evil.example/member/login/redirect" - ) - ) - assertFalse( - isCmbLoginRedirectUrl( - "http://epay92.ahu.edu.cn/member/login/redirect" - ) - ) - - assertEquals( - "https://epay92.ahu.edu.cn/cashier-mobile/cashier", - buildCmbHttpsCashierUrl( - "http://epay92.ahu.edu.cn/cashier-mobile/cashier" - ) - ) - assertEquals( - "https://epay92.ahu.edu.cn/cashier-mobile/cashier?ticket=hidden&embed=true", - buildCmbHttpsCashierUrl( - "http://epay92.ahu.edu.cn/cashier-mobile/cashier?ticket=hidden&embed=true" - ) - ) - assertNull( - buildCmbHttpsCashierUrl( - "https://epay92.ahu.edu.cn/cashier-mobile/cashier" - ) - ) - assertNull( - buildCmbHttpsCashierUrl( - "http://epay92.ahu.edu.cn.evil.example/cashier-mobile/cashier" - ) - ) - } - - @Test - fun mainFrameAllowlistIncludesTheCmbLoginRedirectOnlyOnTheTrustedOrigin() { - assertTrue( - isCmbRechargeAllowedMainFrameUrl( - "https://epay92.ahu.edu.cn/member/login/redirect?ticket=hidden" - ) - ) - assertTrue( - isCmbRechargeAllowedMainFrameUrl( - "https://epay92.ahu.edu.cn/cashier-mobile/charge" - ) - ) - assertFalse( - isCmbRechargeAllowedMainFrameUrl( - "http://epay92.ahu.edu.cn/member/login/redirect" - ) - ) - assertFalse( - isCmbRechargeAllowedMainFrameUrl( - "https://epay92.ahu.edu.cn.evil.example/member/login/redirect" - ) - ) - assertFalse( - isCmbRechargeAllowedMainFrameUrl( - "https://epay92.ahu.edu.cn/member/login/redirect/other" - ) - ) - } - - @Test - fun normalizedOverlayBoundsAreParsedAndValidated() { - val bounds = assertNotNull( - parseCmbRechargeNormalizedBounds("[0.05,0.72,0.90,0.08]") - ) - assertTrue( - shouldConfirmCmbRechargeSuccess( - "https://epay92.ahu.edu.cn/cashier-mobile/chargeResult", - bounds - ) - ) - assertFalse( - shouldConfirmCmbRechargeSuccess( - "https://epay92.ahu.edu.cn/cashier-mobile/chargeResult", - null - ) - ) - assertFalse( - shouldConfirmCmbRechargeSuccess( - "http://epay92.ahu.edu.cn/cashier-mobile/chargeResult", - bounds - ) - ) - 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 + ) + } + ) + } +} From 317f2e6cf253640ff426532a71fae83e83fc0e7e Mon Sep 17 00:00:00 2001 From: InChange-Jiang <316875401+InChange-Jiang@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:22:33 +0800 Subject: [PATCH 08/29] =?UTF-8?q?feat:=20=E5=AD=A6=E4=B9=A0=E9=80=9A=20tab?= =?UTF-8?q?=20=E6=96=B0=E5=A2=9E=E9=A6=96=E6=AC=A1=E5=BC=95=E5=AF=BC?= =?UTF-8?q?=E6=B0=94=E6=B3=A1=EF=BC=88=E6=9B=9C=E5=85=89=E5=88=86=E6=94=AF?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首次进入学习通页时在底部导航第三 tab 上方弹出一次性玻璃气泡, 提示"再次点击可切换日程 / 课程页";点击气泡或完成一次切换后 永久消失(SharedPreferences 标记)。玻璃材质无条件启用, 复用"猜你想用"气泡配方(vibrancy+blur+lens 实时采样), 带 primary 灯泡图标与呼吸小三角。 --- .../com/ahu/ahutong/ui/screen/BottomNavBar.kt | 184 +++++++++++++++++- 1 file changed, 181 insertions(+), 3 deletions(-) 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 cfdccb3e..6da71590 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/BottomNavBar.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/BottomNavBar.kt @@ -1,15 +1,32 @@ package com.ahu.ahutong.ui.screen +import android.content.Context +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.RepeatMode +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.animation.fadeIn +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.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.material.icons.Icons 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.material.icons.rounded.Lightbulb import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar @@ -17,13 +34,25 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Text 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 +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.navigation.NavController import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState @@ -35,6 +64,15 @@ import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.screen.xuexiaotong.XuexiaotongDockState import com.ahu.ahutong.ui.screen.xuexiaotong.XuexiaotongSubTab import com.kyant.backdrop.Backdrop +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 com.kyant.capsule.ContinuousCapsule +import kotlinx.coroutines.delay +import kotlin.math.roundToInt @Composable fun BoxScope.BottomNavBar( @@ -69,6 +107,23 @@ private fun BoxScope.RadiantBottomNavBar( navController: NavHostController, backdrop: Backdrop ) { + // 首次引导:学习通 tab 兼具「日程/课程」轮换切换,新用户不易发现, + // 首次进入学习通页时在其上方弹一次气泡提示,点击气泡或完成一次切换后永久消失。 + val context = LocalContext.current + val guidePrefs = remember { + context.getSharedPreferences("app_guide", Context.MODE_PRIVATE) + } + var tabGuideShown by remember { + mutableStateOf(guidePrefs.getBoolean("xxt_tab_guide_shown", false)) + } + var tabsBounds by remember { mutableStateOf(null) } + fun dismissTabGuide() { + if (!tabGuideShown) { + tabGuideShown = true + guidePrefs.edit().putBoolean("xxt_tab_guide_shown", true).apply() + } + } + val onXuexiaotongSub = XuexiaotongDockState.tab == XuexiaotongSubTab.SCHEDULE val destinations = listOf( RadiantDestination("home", "主页", painterResource(R.drawable.ic_nav_home)), @@ -87,7 +142,10 @@ private fun BoxScope.RadiantBottomNavBar( fun onTabTapped(route: String) { if (route == navController.currentBackStackEntry?.destination?.route) { - if (route == "xuexiaotong") XuexiaotongDockState.toggle() + if (route == "xuexiaotong") { + XuexiaotongDockState.toggle() + dismissTabGuide() + } return } navController.navigatePreservingHome(route) @@ -117,7 +175,9 @@ private fun BoxScope.RadiantBottomNavBar( }, backdrop = backdrop, tabsCount = destinations.size, - modifier = Modifier.padding(horizontal = 36.dp) + modifier = Modifier + .padding(horizontal = 36.dp) + .onGloballyPositioned { tabsBounds = it.boundsInWindow() } ) { destinations.forEach { destination -> val selected = selectedRoute == destination.route @@ -140,7 +200,8 @@ private fun BoxScope.RadiantBottomNavBar( NavigationBar( modifier = Modifier .fillMaxWidth() - .align(Alignment.BottomCenter), + .align(Alignment.BottomCenter) + .onGloballyPositioned { tabsBounds = it.boundsInWindow() }, containerColor = MaterialTheme.colorScheme.surfaceContainer, tonalElevation = 0.dp ) { @@ -167,6 +228,123 @@ private fun BoxScope.RadiantBottomNavBar( } } } + + if (!tabGuideShown && selectedRoute == "xuexiaotong") { + tabsBounds?.let { bounds -> + var overlayOrigin by remember { mutableStateOf(Offset.Zero) } + var guideVisible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(350) + guideVisible = true + } + Box( + modifier = Modifier + .fillMaxSize() + .align(Alignment.TopStart) + .onGloballyPositioned { overlayOrigin = it.boundsInWindow().topLeft } + ) { + AnimatedVisibility( + visible = guideVisible, + enter = fadeIn(tween(150)) + slideInVertically(tween(150)) { it / 3 } + ) { + AnchoredGuideBubble( + anchorCenterX = { bounds.left + bounds.width * 0.625f - overlayOrigin.x }, + anchorTopY = { bounds.top - overlayOrigin.y }, + backdrop = backdrop, + text = "再次点击可切换日程 / 课程页", + onDismiss = { dismissTabGuide() } + ) + } + } + } + } +} + +// 将气泡内容锚定到指定坐标:水平居中对齐锚点(贴屏幕边缘时收边)、底部位于锚点上方 +@Composable +private fun AnchoredGuideBubble( + anchorCenterX: () -> Float, + anchorTopY: () -> Float, + backdrop: Backdrop, + text: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + Layout( + content = { + GuideBubbleCard(text = text, backdrop = backdrop, onDismiss = onDismiss) + }, + modifier = modifier + ) { measurables, constraints -> + val placeable = measurables.first().measure( + constraints.copy(minWidth = 0, minHeight = 0) + ) + val margin = 10.dp.roundToPx() + val parentWidth = constraints.maxWidth + val anchorX = anchorCenterX().roundToInt() + val x = (anchorX - placeable.width / 2) + .coerceIn(margin, (parentWidth - placeable.width - margin).coerceAtLeast(margin)) + val y = anchorTopY().roundToInt() - placeable.height - 10.dp.roundToPx() + layout(parentWidth, constraints.maxHeight) { + placeable.placeRelative(x, y) + } + } +} + +// 引导气泡卡:曜光分支无条件走玻璃材质(vibrancy+blur+lens 实时采样背后页面内容), +// 不随液态玻璃开关切换;造型与"猜你想用"气泡完全同款(ContinuousCapsule 胶囊), +// 带 primary 色灯泡图标,整体上下呼吸浮动引导视线指向下方按钮。 +@Composable +private fun GuideBubbleCard( + text: String, + backdrop: Backdrop, + onDismiss: () -> Unit +) { + val glassContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.64f) + val infiniteTransition = rememberInfiniteTransition(label = "guideBubble") + val bubbleBob by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(900), + repeatMode = RepeatMode.Reverse + ), + label = "bubbleBob" + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .graphicsLayer { translationY = bubbleBob * 3.dp.toPx() } + .drawBackdrop( + backdrop = backdrop, + shape = { ContinuousCapsule }, + effects = { + vibrancy() + blur(8f.dp.toPx()) + lens(24f.dp.toPx(), 24f.dp.toPx()) + }, + highlight = { Highlight.Default }, + shadow = { Shadow() }, + onDrawSurface = { drawRect(glassContainerColor) } + ) + .clickable { onDismiss() } + .padding(horizontal = 14.dp, vertical = 9.dp) + ) { + Icon( + Icons.Rounded.Lightbulb, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = text, + fontSize = 12.sp, + lineHeight = 16.sp, + color = MaterialTheme.colorScheme.onSurface + ) + } } // ==================== 经典版:小工具第三 tab(原样式) ==================== From ff191194aa88e0d5800609a3b04a5048f2d1c2fd Mon Sep 17 00:00:00 2001 From: InChange-Jiang <316875401+InChange-Jiang@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:12:53 +0800 Subject: [PATCH 09/29] =?UTF-8?q?style:=20GlassCard=20=E7=8E=BB=E7=92=83?= =?UTF-8?q?=E5=8D=A1=E9=98=B4=E5=BD=B1=E5=87=8F=E9=87=8D=EF=BC=88=E6=9B=9C?= =?UTF-8?q?=E5=85=89=E5=88=86=E6=94=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 默认投影高度 14dp → 10dp,缓解缴费页(电控/网费/浴室/校园卡)表单卡片阴影过重的问题。投影色保持 shadow() 默认纯黑不变,仅通过高度控制重量;全局玻璃卡(含成绩单/天气页)同步生效。 --- .../ahu/ahutong/ui/components/GlassCard.kt | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/ahu/ahutong/ui/components/GlassCard.kt b/app/src/main/java/com/ahu/ahutong/ui/components/GlassCard.kt index e3eead30..7d2745dc 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/components/GlassCard.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/components/GlassCard.kt @@ -23,7 +23,7 @@ import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape * libhwui 无限递归崩溃(见 ChangeableUI §7.3),故这里统一用伪玻璃模拟,零崩溃风险。 * * @param overlayColor 玻璃上额外叠的一层底色(如雨伞卡的蓝/绿语义色薄层);非玻璃分支忽略 - * @param glassShadow 玻璃底投影高度(Radiant 生效) + * @param glassShadow 玻璃底投影高度(Radiant 生效),默认 10dp(较初版 14dp 减轻) */ @Composable fun GlassCard( @@ -31,7 +31,7 @@ fun GlassCard( containerColor: Color = MaterialTheme.colorScheme.surfaceVariant, overlayColor: Color? = null, shape: Shape = SmoothRoundedCornerShape(24.dp), - glassShadow: androidx.compose.ui.unit.Dp? = 14.dp, + glassShadow: androidx.compose.ui.unit.Dp? = 10.dp, content: @Composable () -> Unit = {} ) { val glass = isRadiantUi @@ -41,11 +41,26 @@ fun GlassCard( } else { containerColor } + // 柔影配色:保持 shadow() 默认纯黑投影,仅通过高度控制重量 + val softSpot = Color.Black + val softAmbient = Color.Black Box( modifier = modifier.then( if (glass) { Modifier - .then(if (glassShadow != null) Modifier.shadow(glassShadow, shape, clip = false) else Modifier) + .then( + if (glassShadow != null) { + Modifier.shadow( + glassShadow, + shape, + clip = false, + spotColor = softSpot, + ambientColor = softAmbient + ) + } else { + Modifier + } + ) .background(base, shape) .border(1.dp, Color.White.copy(alpha = 0.28f), shape) } else { From 6e70235c50b8037d87636315716a0a343374ed2e Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Fri, 4 Sep 2026 23:35:00 +0800 Subject: [PATCH 10/29] chore(merge): checkpoint PR 13 and 14 integration --- .../com/ahu/ahutong/ui/screen/BottomNavBar.kt | 241 +++++++++++++++--- .../java/com/ahu/ahutong/ui/screen/Main.kt | 37 ++- .../com/ahu/ahutong/ui/screen/main/Home.kt | 134 +++++++--- .../ahutong/ui/screen/main/home/AtAGlance.kt | 89 +++++++ .../ahutong/ui/screen/main/home/CampusCard.kt | 43 +++- .../ui/screen/main/home/HomeWidgetEditor.kt | 216 +++++++++++++++- .../xuexiaotong/XuexiaotongLoginScreen.kt | 136 +++++----- .../ui/theme/RadiantThemeArchitectureTest.kt | 49 ++++ 8 files changed, 804 insertions(+), 141 deletions(-) create mode 100644 app/src/test/java/com/ahu/ahutong/ui/theme/RadiantThemeArchitectureTest.kt 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 4406b4b2..45a5bd6d 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/BottomNavBar.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/BottomNavBar.kt @@ -1,5 +1,15 @@ package com.ahu.ahutong.ui.screen +import android.content.Context +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -14,6 +24,7 @@ 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.material.icons.rounded.Lightbulb import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar as MaterialNavigationBar @@ -21,16 +32,31 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Text 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 +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import com.ahu.ahutong.R +import com.ahu.ahutong.data.model.AppUiTheme 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.ahu.ahutong.ui.components.LocalIsLiquidGlassEnabled +import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.components.isRadiantUi +import com.ahu.ahutong.ui.screen.xuexiaotong.XuexiaotongDockState +import com.ahu.ahutong.ui.screen.xuexiaotong.XuexiaotongSubTab import com.kyant.backdrop.Backdrop +import com.kyant.capsule.ContinuousCapsule +import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel +import kotlinx.coroutines.delay import top.yukonga.miuix.kmp.basic.NavigationBar as MiuixNavigationBar import top.yukonga.miuix.kmp.basic.NavigationItem as MiuixNavigationItem @@ -41,7 +67,13 @@ private data class BottomDestination( val unselectedIcon: ImageVector ) -private val bottomDestinations = listOf( +private data class RadiantDestination( + val route: String, + val label: String, + @param:DrawableRes val iconId: Int +) + +private val classicDestinations = listOf( BottomDestination("home", "主页", Icons.Filled.Home, Icons.Outlined.Home), BottomDestination("schedule", "课表", Icons.Filled.TableChart, Icons.Outlined.TableChart), BottomDestination("tools", "小工具", Icons.Filled.Build, Icons.Outlined.Build), @@ -54,7 +86,64 @@ fun BoxScope.BottomNavBar( selectedRoute: String?, onDestinationSelected: (String) -> Unit ) { - if (selectedRoute !in bottomDestinations.map { it.route }) return + if (isRadiantUi) { + RadiantBottomNavBar(backdrop, selectedRoute, onDestinationSelected) + } else { + ClassicBottomNavBar(backdrop, selectedRoute, onDestinationSelected) + } +} + +@Composable +private fun BoxScope.RadiantBottomNavBar( + backdrop: Backdrop, + selectedRoute: String?, + onDestinationSelected: (String) -> Unit +) { + val context = LocalContext.current + val guidePreferences = remember { + context.getSharedPreferences("app_guide", Context.MODE_PRIVATE) + } + var guideDismissed by remember { + mutableStateOf(guidePreferences.getBoolean("xxt_tab_guide_shown", false)) + } + var guideVisible by remember { mutableStateOf(false) } + fun dismissGuide() { + if (!guideDismissed) { + guideDismissed = true + guidePreferences.edit().putBoolean("xxt_tab_guide_shown", true).apply() + } + guideVisible = false + } + + val showingSchedule = XuexiaotongDockState.tab == XuexiaotongSubTab.SCHEDULE + val destinations = listOf( + RadiantDestination("home", "主页", R.drawable.ic_nav_home), + RadiantDestination("schedule", "课表", R.drawable.ic_nav_schedule), + RadiantDestination( + "xuexiaotong", + if (showingSchedule) "日程" else "课程", + if (showingSchedule) R.drawable.ic_nav_plan else R.drawable.ic_nav_degree_hat + ), + RadiantDestination("settings", "设置", R.drawable.ic_nav_settings) + ) + if (selectedRoute !in destinations.map { it.route }) return + + fun select(route: String) { + if (route == "xuexiaotong" && route == selectedRoute) { + dismissGuide() + XuexiaotongDockState.toggle() + } else { + onDestinationSelected(route) + } + } + + LaunchedEffect(selectedRoute, guideDismissed) { + guideVisible = false + if (selectedRoute == "xuexiaotong" && !guideDismissed) { + delay(350) + guideVisible = true + } + } if (LocalIsLiquidGlassEnabled.current) { Row( @@ -66,22 +155,118 @@ fun BoxScope.BottomNavBar( ) { LiquidBottomTabs( selectedTabIndex = { - bottomDestinations.indexOfFirst { it.route == selectedRoute }.coerceAtLeast(0) + destinations.indexOfFirst { it.route == selectedRoute }.coerceAtLeast(0) }, - onTabSelected = { index -> - onDestinationSelected(bottomDestinations[index].route) + onTabSelected = { select(destinations[it].route) }, + onCurrentTabTapped = { selectedRoute?.let(::select) }, + backdrop = backdrop, + tabsCount = destinations.size, + modifier = Modifier.padding(horizontal = 36.dp) + ) { + destinations.forEach { destination -> + val selected = selectedRoute == destination.route + LiquidBottomTab( + selected = selected, + onClick = { select(destination.route) } + ) { + Icon( + painter = painterResource(destination.iconId), + contentDescription = destination.label + ) + Text(destination.label, style = MaterialTheme.typography.labelMedium) + } + } + } + } + } else { + MaterialNavigationBar( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + containerColor = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 0.dp + ) { + destinations.forEach { destination -> + val selected = selectedRoute == destination.route + NavigationBarItem( + selected = selected, + onClick = { select(destination.route) }, + icon = { + Icon( + painter = painterResource(destination.iconId), + contentDescription = destination.label + ) + }, + label = { Text(destination.label) }, + colors = appNavigationBarItemColors() + ) + } + } + } + + AnimatedVisibility( + visible = guideVisible, + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = 104.dp), + enter = fadeIn(tween(150)) + slideInVertically(tween(150)) { it / 3 }, + exit = fadeOut(tween(120)) + slideOutVertically(tween(120)) { it / 3 } + ) { + Row( + modifier = Modifier + .appLiquidGlassSurface( + shape = ContinuousCapsule, + fallbackColor = MaterialTheme.colorScheme.surfaceContainerHigh, + level = LiquidGlassSurfaceLevel.Floating, + backdrop = backdrop, + backdropSamplingEnabled = true + ) + .clickable(onClick = ::dismissGuide) + .padding(horizontal = 18.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.Lightbulb, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text("再次点击可切换日程 / 课程页") + } + } +} + +@Composable +private fun BoxScope.ClassicBottomNavBar( + backdrop: Backdrop, + selectedRoute: String?, + onDestinationSelected: (String) -> Unit +) { + if (selectedRoute !in classicDestinations.map { it.route }) return + + if (LocalIsLiquidGlassEnabled.current) { + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(vertical = 16.dp) + .navigationBarsPadding() + ) { + LiquidBottomTabs( + selectedTabIndex = { + classicDestinations.indexOfFirst { it.route == selectedRoute }.coerceAtLeast(0) }, + onTabSelected = { onDestinationSelected(classicDestinations[it].route) }, backdrop = backdrop, - tabsCount = bottomDestinations.size, + tabsCount = classicDestinations.size, modifier = Modifier.padding(horizontal = 36.dp) ) { - bottomDestinations.forEach { destination -> + classicDestinations.forEach { destination -> val selected = selectedRoute == destination.route LiquidBottomTab( selected = selected, - onClick = { - onDestinationSelected(destination.route) - } + onClick = { onDestinationSelected(destination.route) } ) { Icon( imageVector = if (selected) { @@ -91,20 +276,17 @@ fun BoxScope.BottomNavBar( }, contentDescription = destination.label ) - Text( - text = destination.label, - style = MaterialTheme.typography.labelMedium - ) + Text(destination.label, style = MaterialTheme.typography.labelMedium) } } } } } else if (LocalAppUiTheme.current == AppUiTheme.MIUIX) { - val selectedIndex = bottomDestinations + val selectedIndex = classicDestinations .indexOfFirst { it.route == selectedRoute } .coerceAtLeast(0) MiuixNavigationBar( - items = bottomDestinations.mapIndexed { index, destination -> + items = classicDestinations.mapIndexed { index, destination -> MiuixNavigationItem( label = destination.label, icon = if (index == selectedIndex) { @@ -115,9 +297,7 @@ fun BoxScope.BottomNavBar( ) }, selected = selectedIndex, - onClick = { index -> - onDestinationSelected(bottomDestinations[index].route) - }, + onClick = { onDestinationSelected(classicDestinations[it].route) }, modifier = Modifier .fillMaxWidth() .align(Alignment.BottomCenter) @@ -130,7 +310,7 @@ fun BoxScope.BottomNavBar( containerColor = MaterialTheme.colorScheme.surfaceContainer, tonalElevation = 0.dp ) { - bottomDestinations.forEach { destination -> + classicDestinations.forEach { destination -> val selected = selectedRoute == destination.route NavigationBarItem( selected = selected, @@ -146,15 +326,18 @@ fun BoxScope.BottomNavBar( ) }, label = { Text(destination.label) }, - colors = NavigationBarItemDefaults.colors( - selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer, - selectedTextColor = MaterialTheme.colorScheme.onSurface, - indicatorColor = MaterialTheme.colorScheme.secondaryContainer, - unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant - ) + colors = appNavigationBarItemColors() ) } } } } + +@Composable +private fun appNavigationBarItemColors() = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + selectedTextColor = MaterialTheme.colorScheme.onSurface, + indicatorColor = MaterialTheme.colorScheme.secondaryContainer, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant +) 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 ac717694..776127de 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 @@ -42,6 +42,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import com.ahu.ahutong.appwidget.ScheduleAppWidgetReceiver import com.ahu.ahutong.data.gray.GrayFeatures import com.ahu.ahutong.data.gray.GrayReleaseManager +import com.ahu.ahutong.data.model.AppUiTheme import com.ahu.ahutong.ui.screen.main.BathroomDeposit import com.ahu.ahutong.ui.screen.main.CardBalanceDeposit import com.ahu.ahutong.ui.screen.main.ElectricityDeposit @@ -150,6 +151,17 @@ fun Main( } } + LaunchedEffect(appUiTheme) { + if (appUiTheme == AppUiTheme.RADIANT && primaryRoute == "tools") { + selectPrimaryDestination("home") + } else if (appUiTheme != AppUiTheme.RADIANT && currentRoute == "xuexiaotong") { + navController.navigate("home") { + popUpTo("home") { inclusive = false } + launchSingleTop = true + } + } + } + LaunchedEffect(effectiveRoute) { val route = effectiveRoute ?: return@LaunchedEffect val previousRoute = navController.previousBackStackEntry?.destination?.route @@ -430,9 +442,25 @@ fun Main( } BottomNavBar( backdrop = backdrop, - selectedRoute = primaryRoute.takeIf { currentRoute == "home" }, + selectedRoute = when { + currentRoute == "home" -> primaryRoute + appUiTheme == AppUiTheme.RADIANT && currentRoute == "xuexiaotong" -> currentRoute + else -> null + }, onDestinationSelected = { route -> - scope.launch { selectPrimaryDestination(route) } + if (route == "xuexiaotong") { + navController.navigate(route) { launchSingleTop = true } + } else { + scope.launch { + selectPrimaryDestination(route) + if (currentRoute != "home") { + navController.navigate("home") { + popUpTo("home") { inclusive = false } + launchSingleTop = true + } + } + } + } } ) val productUiBlocked = effectiveRoute == "login" || effectiveRoute == "setup" || @@ -445,7 +473,10 @@ fun Main( backdrop = backdrop, blocked = productUiBlocked, hiddenForDiagnostics = diagnosticsRouteVisible, - bottomSpacing = if (effectiveRoute in primaryDestinationRoutes) { + bottomSpacing = if ( + effectiveRoute in primaryDestinationRoutes || + appUiTheme == AppUiTheme.RADIANT && currentRoute == "xuexiaotong" + ) { 88.dp } else { 16.dp 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 f1f78f77..6e7c3a2e 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,5 +1,6 @@ package com.ahu.ahutong.ui.screen.main +import androidx.compose.foundation.background import androidx.activity.compose.BackHandler import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown @@ -8,10 +9,14 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -37,6 +42,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.boundsInRoot @@ -46,6 +52,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import androidx.lifecycle.viewmodel.compose.viewModel import com.ahu.ahutong.BuildConfig import com.ahu.ahutong.R @@ -58,7 +65,10 @@ 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.components.LocalIsLiquidGlassEnabled +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.screen.main.home.AtAGlance +import com.ahu.ahutong.ui.screen.main.home.HomeDateRow import com.ahu.ahutong.ui.screen.main.home.HomeWeatherWidget import com.ahu.ahutong.ui.screen.main.home.HomeWidgetDragOverlay import com.ahu.ahutong.ui.screen.main.home.HomeWidgetLibrarySheet @@ -137,6 +147,15 @@ fun Home( emptyList() } } + val radiant = isRadiantUi + val slotCount = if (radiant) { + HomeWidgetRegistry.slotCountRadiant + } else { + HomeWidgetRegistry.slotCountClassic + } + val knownWidgetIds = remember(radiant) { + HomeWidgetRegistry.availableWidgets(radiant).mapTo(mutableSetOf()) { it.id } + } val initialCalendar = remember { Calendar.getInstance(Locale.CHINA) } var currentDateText by remember { mutableStateOf("") } var currentMinutes by remember { @@ -145,8 +164,13 @@ fun Home( ) } var isEditingHome by remember { mutableStateOf(false) } - var homeWidgetSlots by remember { - mutableStateOf(normalizeHomeWidgetSlots(listOf("bathroom", "electricity"))) + var homeWidgetSlots by remember(radiant) { + val initialSlots = if (radiant && !AHUCache.hasCustomHomeWidgetSlots()) { + HomeWidgetRegistry.defaultSlotsRadiant + } else { + listOf("bathroom", "electricity") + } + mutableStateOf(normalizeHomeWidgetSlots(initialSlots, slotCount, knownWidgetIds)) } val slotBounds = remember { mutableStateMapOf() } var libraryBounds by remember { mutableStateOf(null) } @@ -158,7 +182,8 @@ fun Home( drag = it, slots = homeWidgetSlots, slotBounds = slotBounds, - dropSlopPx = dropSlopPx + dropSlopPx = dropSlopPx, + slotCount = slotCount ) } val weatherHomeConfig by produceState( @@ -169,7 +194,7 @@ fun Home( } fun saveHomeWidgetSlots(slots: List) { - val normalizedSlots = normalizeHomeWidgetSlots(slots) + val normalizedSlots = normalizeHomeWidgetSlots(slots, slotCount, knownWidgetIds) homeWidgetSlots = normalizedSlots AHUCache.saveHomeWidgetSlots(normalizedSlots) } @@ -202,7 +227,8 @@ fun Home( drag = drag, slots = homeWidgetSlots, slotBounds = slotBounds, - dropSlopPx = dropSlopPx + dropSlopPx = dropSlopPx, + slotCount = slotCount ) if (drag.sourceSlot != null && libraryBounds?.contains(dragCenter) == true) { @@ -284,9 +310,14 @@ fun Home( exitHomeEditMode() } - LaunchedEffect(Unit) { + LaunchedEffect(radiant) { homeWidgetSlots = withContext(Dispatchers.IO) { - normalizeHomeWidgetSlots(AHUCache.getHomeWidgetSlots()) + val savedSlots = if (radiant && !AHUCache.hasCustomHomeWidgetSlots()) { + HomeWidgetRegistry.defaultSlotsRadiant + } else { + AHUCache.getHomeWidgetSlots() + } + normalizeHomeWidgetSlots(savedSlots, slotCount, knownWidgetIds) } } LaunchedEffect(Unit) { @@ -330,6 +361,23 @@ fun Home( exitHomeEditMode() } } + val trailingContent: @Composable RowScope.() -> Unit = { + if (BuildConfig.DEBUG) { + DebugBuildBadge() + } + if ( + !isEditingHome && + weatherHomeConfig.showOnHome && + weatherHomeConfig.mode == WeatherHomeMode.Compact + ) { + HomeWeatherWidget( + onClick = { navController.navigate("weather") }, + modifier = Modifier.padding(start = 12.dp), + config = weatherHomeConfig, + mode = WeatherHomeMode.Compact + ) + } + } Box( modifier = Modifier .fillMaxSize() @@ -379,8 +427,15 @@ fun Home( .fillMaxSize() .verticalScroll(rememberScrollState()) .systemBarsPadding() - .padding(bottom = if (isEditingHome) 520.dp else 96.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) + .padding( + top = if (radiant) 48.dp else 0.dp, + bottom = if (isEditingHome) 520.dp else 96.dp + ), + verticalArrangement = if (radiant) { + Arrangement.Center + } else { + Arrangement.spacedBy(24.dp) + } ) { AtAGlance( todayCourses = todayCourses, @@ -389,23 +444,7 @@ fun Home( onOpenSchedule = onOpenSchedule, isInSemester = isInSemester, enabled = !isEditingHome, - trailingContent = { - if (BuildConfig.DEBUG) { - DebugBuildBadge() - } - if ( - !isEditingHome && - weatherHomeConfig.showOnHome && - weatherHomeConfig.mode == WeatherHomeMode.Compact - ) { - HomeWeatherWidget( - onClick = { navController.navigate("weather") }, - modifier = Modifier.padding(start = 12.dp), - config = weatherHomeConfig, - mode = WeatherHomeMode.Compact - ) - } - } + trailingContent = trailingContent ) if (todayCourses.isNotEmpty()) { TodayCourseList( @@ -450,8 +489,35 @@ fun Home( ) } + if (radiant) { + val headerBackground = if (LocalIsLiquidGlassEnabled.current) { + MaterialTheme.colorScheme.surfaceContainerLowest + } else { + MaterialTheme.colorScheme.surface + } + Box( + modifier = Modifier + .fillMaxWidth() + .zIndex(20f) + .background( + Brush.verticalGradient( + 0f to headerBackground, + 0.35f to headerBackground, + 0.68f to headerBackground.copy(alpha = 0.85f), + 1f to headerBackground.copy(alpha = 0f) + ) + ) + .statusBarsPadding() + .padding(top = 12.dp) + ) { + HomeDateRow(trailingContent = trailingContent) + Spacer(modifier = Modifier.height(24.dp)) + } + } + val placedWidgetIds = homeWidgetSlots.filterNotNull().toSet() - val availableWidgets = HomeWidgetRegistry.widgets.filter { it.id !in placedWidgetIds } + val availableWidgets = HomeWidgetRegistry.availableWidgets(radiant) + .filter { it.id !in placedWidgetIds } val isDraggingFromLibrary = activeDrag != null && activeDrag?.sourceSlot == null HomeWidgetLibrarySheet( visible = isEditingHome, @@ -538,10 +604,13 @@ private fun DebugBuildBadge() { } } -private fun normalizeHomeWidgetSlots(slots: List): List { - val knownIds = HomeWidgetRegistry.widgetById.keys +private fun normalizeHomeWidgetSlots( + slots: List, + slotCount: Int, + knownIds: Set +): List { val seen = mutableSetOf() - return List(HomeWidgetRegistry.slotCount) { index -> + return List(slotCount) { index -> val id = slots.getOrNull(index)?.takeIf { it in knownIds } if (id != null && seen.add(id)) id else null } @@ -551,11 +620,12 @@ private fun findHomeWidgetDropSlot( drag: ActiveHomeWidgetDrag, slots: List, slotBounds: Map, - dropSlopPx: Float + dropSlopPx: Float, + slotCount: Int ): Int? { val center = drag.center return slotBounds - .filterKeys { it in 1..HomeWidgetRegistry.slotCount } + .filterKeys { it in 1..slotCount } .mapNotNull { (slotIndex, bounds) -> if (!bounds.expandedBy(dropSlopPx).contains(center)) return@mapNotNull null if (drag.sourceSlot == null && slots.getOrNull(slotIndex - 1) != null) return@mapNotNull null 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 cc0d42ba..1d6f259b 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 @@ -17,10 +17,12 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset 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 com.ahu.ahutong.data.model.Course +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.ScheduleViewModel import com.kyant.monet.a1 @@ -36,6 +38,17 @@ fun AtAGlance( enabled: Boolean = true, trailingContent: @Composable RowScope.() -> Unit = {} ) { + if (isRadiantUi) { + RadiantAtAGlance( + todayCourses = todayCourses, + currentMinutes = currentMinutes, + onOpenSchedule = onOpenSchedule, + isInSemester = isInSemester, + enabled = enabled + ) + return + } + val currentCourse = todayCourses.find { currentMinutes in ScheduleViewModel.getCourseTimeRangeInMinutes(it) } @@ -158,3 +171,79 @@ fun AtAGlance( } } } + +@Composable +private fun RadiantAtAGlance( + todayCourses: List, + currentMinutes: Int, + onOpenSchedule: () -> Unit, + isInSemester: Boolean, + enabled: Boolean +) { + val currentCourse = todayCourses.find { + currentMinutes in ScheduleViewModel.getCourseTimeRangeInMinutes(it) + } + val currentCourseIndex = todayCourses.indexOfFirst { + val range = ScheduleViewModel.getCourseTimeRangeInMinutes(it) + currentMinutes in range || currentMinutes < range.first + }.takeIf { it != -1 } ?: todayCourses.lastIndex + val hasRemainingCourses = todayCourses.isNotEmpty() && + currentMinutes <= ScheduleViewModel.getCourseTimeRangeInMinutes(todayCourses.last()).last + val headline = when { + currentCourse != null -> "正在上课 · ${currentCourse.name}" + hasRemainingCourses -> "下节课是 ${todayCourses[currentCourseIndex].name}" + !isInSemester -> "假期中" + else -> "今日空闲" + } + val subtitle = when { + currentCourse != null -> { + val duration = ScheduleViewModel.getCourseTimeRangeInMinutes(currentCourse).last - + currentMinutes + "距下课还有 ${formatCourseDuration(duration)}" + } + + hasRemainingCourses -> { + val nextCourse = todayCourses[currentCourseIndex] + val duration = ScheduleViewModel.getCourseTimeRangeInMinutes(nextCourse).first - + currentMinutes + "还有 ${formatCourseDuration(duration)},在 ${nextCourse.location}" + } + + !isInSemester -> "准备您自己的安排吧" + else -> "今天暂无课程安排" + } + + Column( + modifier = Modifier + .fillMaxWidth() + .then(if (enabled) Modifier.clickable(onClick = onOpenSchedule) else Modifier) + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = headline, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontSize = 30.sp, + fontWeight = FontWeight.Bold, + lineHeight = 34.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.headlineLarge + ) + Text( + text = subtitle, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Clip, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +private fun formatCourseDuration(durationMinutes: Int): String = when { + durationMinutes % 60 == 0 -> "${durationMinutes / 60}小时整" + durationMinutes > 60 -> "${durationMinutes / 60}小时${durationMinutes % 60}分钟" + else -> "${durationMinutes}分钟" +} 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 51aaf5a5..c4d0290f 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 @@ -10,7 +10,6 @@ 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.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -42,6 +41,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -64,18 +64,20 @@ 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.ahu.ahutong.ui.components.isRadiantUi import com.kyant.monet.n1 import com.kyant.monet.withNight import java.util.Locale @OptIn(ExperimentalAnimationApi::class) @Composable -fun RowScope.CampusCard( +fun CampusCard( balance: Double, transitionBalance: Double, onRefreshBalance: () -> Unit, navController: NavController, - enabled: Boolean = true + enabled: Boolean = true, + modifier: Modifier = Modifier ) { val context = LocalContext.current val preferencesManager = remember { PreferencesManager(context = context) } @@ -121,8 +123,7 @@ fun RowScope.CampusCard( Box( - modifier = Modifier - .weight(1f) + modifier = modifier ) { if (isQrcode) { QRcodeView( @@ -143,7 +144,7 @@ fun RowScope.CampusCard( enabled = enabled, modifier = Modifier .fillMaxWidth() - .height(140.dp) + .height(if (isRadiantUi) 78.dp else 140.dp) ) } } @@ -216,6 +217,7 @@ private fun CardView( Box( modifier = Modifier .fillMaxHeight() + .then(if (isRadiantUi) Modifier.width(76.dp) else Modifier) .then( if (enabled) { Modifier.clickable { @@ -240,13 +242,32 @@ private fun CardView( Modifier } ) - .padding(16.dp), + .padding(if (isRadiantUi) 8.dp else 16.dp), contentAlignment = Alignment.Center ) { - Text( - text = "充\n值", - style = MaterialTheme.typography.titleMedium - ) + if (isRadiantUi) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_income), + contentDescription = "充值", + modifier = Modifier.size(22.dp) + ) + Spacer(modifier = Modifier.height(3.dp)) + Text( + text = "充值", + fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.labelSmall + ) + } + } else { + Text( + text = "充\n值", + style = MaterialTheme.typography.titleMedium + ) + } } } 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 d4b4c5c8..3c7fc66c 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 @@ -12,6 +12,8 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown @@ -73,7 +75,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.R import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.a1 @@ -99,6 +103,26 @@ fun HomeWidgetSlotLayout( onHomeWidgetDragged: (Offset) -> Unit, onHomeWidgetDragStopped: () -> Unit ) { + if (isRadiantUi) { + RadiantHomeWidgetSlotLayout( + balance = balance, + transitionBalance = transitionBalance, + onRefreshBalance = onRefreshBalance, + navController = navController, + slots = slots, + isEditing = isEditing, + highlightedSlot = highlightedSlot, + draggingWidgetId = draggingWidgetId, + onEnterEdit = onEnterEdit, + onHomeWidgetClick = onHomeWidgetClick, + onSlotPositioned = onSlotPositioned, + onHomeWidgetDragStarted = onHomeWidgetDragStarted, + onHomeWidgetDragged = onHomeWidgetDragged, + onHomeWidgetDragStopped = onHomeWidgetDragStopped + ) + return + } + Column( modifier = Modifier .fillMaxWidth() @@ -116,7 +140,8 @@ fun HomeWidgetSlotLayout( transitionBalance = transitionBalance, onRefreshBalance = onRefreshBalance, navController = navController, - enabled = !isEditing + enabled = !isEditing, + modifier = Modifier.weight(1f) ) val showTopColumn = isEditing || slots.getOrNull(0) != null || slots.getOrNull(1) != null @@ -291,6 +316,8 @@ private fun HomeWidgetSlot( TextHomeWidgetCard( title = spec.title, + iconId = spec.iconId, + tint = spec.tint, isEditing = isEditing, isHighlighted = isHighlighted, modifier = slotModifier @@ -302,11 +329,26 @@ private fun HomeWidgetSlot( @Composable private fun TextHomeWidgetCard( title: String, + iconId: Int, + tint: Color, isEditing: Boolean, isHighlighted: Boolean, modifier: Modifier = Modifier, interactionModifier: Modifier = Modifier ) { + if (isRadiantUi) { + RadiantTextHomeWidgetCard( + title = title, + iconId = iconId, + tint = tint, + isEditing = isEditing, + isHighlighted = isHighlighted, + modifier = modifier, + interactionModifier = interactionModifier + ) + return + } + val shape = SmoothRoundedCornerShape(24.dp) val surfaceModifier = if (isHighlighted) { Modifier @@ -337,6 +379,176 @@ private fun TextHomeWidgetCard( } } +@Composable +private fun RadiantTextHomeWidgetCard( + title: String, + iconId: Int, + tint: Color, + isEditing: Boolean, + isHighlighted: Boolean, + modifier: Modifier = Modifier, + interactionModifier: Modifier = Modifier +) { + val shape = SmoothRoundedCornerShape(18.dp) + Box( + modifier = modifier + .editModeMotion(isEditing) + .then( + if (isEditing || isHighlighted) { + Modifier.border( + width = 1.5.dp, + color = if (isHighlighted) { + 75.a1 withNight 80.a1 + } else { + 60.n1 withNight 50.n1 + }, + shape = shape + ) + } else { + Modifier + } + ) + .then(interactionModifier) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(iconId), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = tint + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = title, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium + ) + } + } +} + +@Composable +private fun HomeWidgetMoreItem( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .clickable(onClick = onClick) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_more_all), + contentDescription = "更多", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "更多", + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.labelMedium + ) + } + } +} + +@Composable +private fun RadiantHomeWidgetSlotLayout( + balance: Double, + transitionBalance: Double, + onRefreshBalance: () -> Unit, + navController: NavHostController, + slots: List, + isEditing: Boolean, + highlightedSlot: Int?, + draggingWidgetId: String?, + onEnterEdit: () -> Unit, + onHomeWidgetClick: (slotIndex: Int) -> Unit, + onSlotPositioned: (slotIndex: Int, bounds: Rect) -> Unit, + onHomeWidgetDragStarted: (widgetId: String, slotIndex: Int, bounds: Rect) -> Unit, + onHomeWidgetDragged: (Offset) -> Unit, + onHomeWidgetDragStopped: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CampusCard( + balance = balance, + transitionBalance = transitionBalance, + onRefreshBalance = onRefreshBalance, + navController = navController, + enabled = !isEditing, + modifier = Modifier.fillMaxWidth() + ) + + listOf(listOf(1, 2, 3, 4), listOf(5, 6, 7)).forEach { rowSlots -> + val lastRow = rowSlots.last() == 7 + val visibleSlots = if (isEditing) { + rowSlots + } else { + rowSlots.filter { slots.getOrNull(it - 1) != null } + } + if (isEditing || visibleSlots.isNotEmpty() || lastRow) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + visibleSlots.forEach { slotIndex -> + val widgetId = slots.getOrNull(slotIndex - 1) + HomeWidgetSlot( + slotIndex = slotIndex, + widgetId = widgetId, + isEditing = isEditing, + isHighlighted = highlightedSlot == slotIndex, + isDragging = draggingWidgetId == widgetId, + modifier = Modifier + .weight(1f) + .height(64.dp), + onEnterEdit = onEnterEdit, + onNavigate = { navController.navigate(it) }, + onClick = onHomeWidgetClick, + onSlotPositioned = onSlotPositioned, + onDragStarted = onHomeWidgetDragStarted, + onDragged = onHomeWidgetDragged, + onDragStopped = onHomeWidgetDragStopped + ) + } + if (lastRow) { + HomeWidgetMoreItem( + onClick = { navController.navigate("widgets") }, + modifier = if (visibleSlots.isEmpty()) { + Modifier.width(68.dp).height(64.dp) + } else { + Modifier.weight(1f).height(64.dp) + } + ) + } + } + } + } + } +} + @Composable private fun EmptyHomeWidgetSlot( isHighlighted: Boolean, @@ -607,6 +819,8 @@ fun HomeWidgetDragOverlay( ) { TextHomeWidgetCard( title = spec.title, + iconId = spec.iconId, + tint = spec.tint, isEditing = false, isHighlighted = false, modifier = Modifier.matchParentSize() diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongLoginScreen.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongLoginScreen.kt index f416bef9..6490b251 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongLoginScreen.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongLoginScreen.kt @@ -1,27 +1,23 @@ package com.ahu.ahutong.ui.screen.xuexiaotong -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -31,7 +27,6 @@ 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.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -40,6 +35,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.ahu.ahutong.data.xuexiaotong.ChaoxingApi import com.ahu.ahutong.data.xuexiaotong.Store +import com.ahu.ahutong.ui.components.AppButton +import com.ahu.ahutong.ui.components.AppButtonVariant +import com.ahu.ahutong.ui.components.AppCard +import com.ahu.ahutong.ui.components.AppCircularProgressIndicator +import com.ahu.ahutong.ui.components.AppTextField import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -49,13 +49,11 @@ fun XuexiaotongLoginScreen( api: ChaoxingApi, onLoginSuccess: () -> Unit ) { - var phone by remember { mutableStateOf("") } - var pwd by remember { mutableStateOf("") } - var showPwd by remember { mutableStateOf(false) } + var account by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var showPassword by remember { mutableStateOf(false) } var loading by remember { mutableStateOf(false) } - var errText by remember { mutableStateOf("") } - var agreed by remember { mutableStateOf(false) } - var showPrivacy by remember { mutableStateOf(false) } + var errorText by remember { mutableStateOf("") } val scope = rememberCoroutineScope() Box( @@ -84,87 +82,95 @@ fun XuexiaotongLoginScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( - text = "登陆凭证仅用于登录学习通,不会上传到任何第三方", + text = "登录凭证仅用于登录学习通,不会上传到任何第三方", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) ) Spacer(modifier = Modifier.height(32.dp)) - Column( - modifier = Modifier - .fillMaxWidth() - .background( - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - RoundedCornerShape(20.dp) - ) - .padding(20.dp) + AppCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(20.dp) ) { - OutlinedTextField( - value = phone, - onValueChange = { phone = it }, - label = { Text("账号") }, - placeholder = { Text("手机号 / 超星号 / 邮箱") }, - singleLine = true, + AppTextField( + value = account, + onValueChange = { account = it }, + label = "账号(手机号 / 超星号 / 邮箱)", keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(14.dp) + modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(12.dp)) - OutlinedTextField( - value = pwd, - onValueChange = { pwd = it }, - label = { Text("密码") }, - placeholder = { Text("请输入密码") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - visualTransformation = if (showPwd) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - androidx.compose.material3.TextButton(onClick = { showPwd = !showPwd }) { - Text(if (showPwd) "隐藏" else "显示", fontSize = 13.sp) - } - }, + Row( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(14.dp) - ) + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AppTextField( + value = password, + onValueChange = { password = it }, + label = "密码", + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = if (showPassword) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + modifier = Modifier.weight(1f) + ) + AppButton( + onClick = { showPassword = !showPassword }, + modifier = Modifier.widthIn(min = 72.dp), + variant = AppButtonVariant.Secondary + ) { + Text(if (showPassword) "隐藏" else "显示", fontSize = 13.sp) + } + } - if (errText.isNotEmpty()) { + if (errorText.isNotEmpty()) { Spacer(modifier = Modifier.height(10.dp)) - Text(errText, color = MaterialTheme.colorScheme.error, fontSize = 13.sp) + Text(errorText, color = MaterialTheme.colorScheme.error, fontSize = 13.sp) } Spacer(modifier = Modifier.height(24.dp)) - Button( + AppButton( onClick = { - errText = "" - if (phone.isBlank()) { errText = "请输入账号"; return@Button } - if (pwd.isBlank()) { errText = "请输入密码"; return@Button } + errorText = "" + if (account.isBlank()) { + errorText = "请输入账号" + return@AppButton + } + if (password.isBlank()) { + errorText = "请输入密码" + return@AppButton + } loading = true scope.launch { try { withContext(Dispatchers.IO) { - api.loginByPassword(phone, pwd) - Store.saveCredential(phone, pwd) + api.loginByPassword(account, password) + Store.saveCredential(account, password) } onLoginSuccess() - } catch (e: Exception) { - errText = e.message ?: "登录失败" + } catch (exception: Exception) { + errorText = exception.message ?: "登录失败" } finally { loading = false } } }, - modifier = Modifier.fillMaxWidth().height(48.dp), - shape = RoundedCornerShape(24.dp), + modifier = Modifier + .fillMaxWidth() + .height(48.dp), enabled = !loading ) { if (loading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = MaterialTheme.colorScheme.onPrimary, + AppCircularProgressIndicator( + size = 20.dp, + color = LocalContentColor.current, strokeWidth = 2.dp ) - Spacer(modifier = Modifier.size(8.dp)) - Text("登录中...") + Spacer(modifier = Modifier.width(8.dp)) + Text("登录中…") } else { Text("登录", fontSize = 16.sp) } @@ -172,4 +178,4 @@ fun XuexiaotongLoginScreen( } } } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/ahu/ahutong/ui/theme/RadiantThemeArchitectureTest.kt b/app/src/test/java/com/ahu/ahutong/ui/theme/RadiantThemeArchitectureTest.kt new file mode 100644 index 00000000..1e4cb728 --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/ui/theme/RadiantThemeArchitectureTest.kt @@ -0,0 +1,49 @@ +package com.ahu.ahutong.ui.theme + +import com.ahu.ahutong.data.model.AppUiTheme +import java.io.File +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RadiantThemeArchitectureTest { + @Test + fun `radiant owns a distinct home and bottom navigation layout`() { + assertTrue(AppUiTheme.LIQUID_GLASS.usesLiquidGlass) + assertTrue(AppUiTheme.RADIANT.usesLiquidGlass) + + val home = source("com/ahu/ahutong/ui/screen/main/Home.kt") + val bottomBar = source("com/ahu/ahutong/ui/screen/BottomNavBar.kt") + val main = source("com/ahu/ahutong/ui/screen/Main.kt") + + assertTrue(home.contains("val radiant = isRadiantUi")) + assertTrue(home.contains("HomeWidgetRegistry.slotCountRadiant")) + assertTrue(home.contains("HomeDateRow(")) + assertTrue(bottomBar.contains("if (isRadiantUi)")) + assertTrue(bottomBar.contains("\"xuexiaotong\"")) + assertTrue(main.contains("currentRoute == \"xuexiaotong\"")) + } + + @Test + fun `xuexiaotong login uses controls that dispatch all app themes`() { + val login = source("com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongLoginScreen.kt") + + assertTrue(login.contains("AppCard(")) + assertTrue(login.contains("AppTextField(")) + assertTrue(login.contains("AppButton(")) + assertTrue(login.contains("AppCircularProgressIndicator(")) + assertFalse(login.contains("androidx.compose.material3.OutlinedTextField")) + assertFalse(login.contains("androidx.compose.material3.Button")) + } + + 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 } + } +} From dbee7f129a2814316c7e02b39aea796739467f97 Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Fri, 4 Sep 2026 23:39:46 +0800 Subject: [PATCH 11/29] fix(cache): isolate home layouts by theme family --- .../java/com/ahu/ahutong/data/dao/AHUCache.kt | 97 ++++++++++++++----- 1 file changed, 75 insertions(+), 22 deletions(-) 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 d3060e3c..83d68ada 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 @@ -24,6 +24,11 @@ import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.tencent.mmkv.MMKV +enum class HomeWidgetLayoutFamily { + CLASSIC, + RADIANT +} + /** * @Author SinkDev * @Date 2021/7/27-16:49 @@ -450,39 +455,73 @@ object AHUCache { userPutString("is_show_widget_dialog", false.toString()) } - private const val HOME_WIDGET_SLOTS_KEY = "home_widget_slots" - private const val HOME_WIDGET_SLOT_COUNT = 8 + // Keep the legacy key as Classic so existing layouts migrate without a copy or downgrade hazard. + private const val HOME_WIDGET_SLOTS_CLASSIC_KEY = "home_widget_slots" + private const val HOME_WIDGET_SLOTS_RADIANT_KEY = "home_widget_slots_radiant" + private const val HOME_WIDGET_SLOT_COUNT_CLASSIC = 8 + private const val HOME_WIDGET_SLOT_COUNT_RADIANT = 7 private data class HomeWidgetSlotsCache( val userId: String?, + val layoutFamily: HomeWidgetLayoutFamily, val slots: List ) @Volatile private var homeWidgetSlotsCache: HomeWidgetSlotsCache? = null - private fun defaultHomeWidgetSlots(): List { - return listOf("bathroom", "electricity") + List(HOME_WIDGET_SLOT_COUNT - 2) { null } - } + private fun homeWidgetSlotsKey(layoutFamily: HomeWidgetLayoutFamily): String = + when (layoutFamily) { + HomeWidgetLayoutFamily.CLASSIC -> HOME_WIDGET_SLOTS_CLASSIC_KEY + HomeWidgetLayoutFamily.RADIANT -> HOME_WIDGET_SLOTS_RADIANT_KEY + } + + private fun homeWidgetSlotCount(layoutFamily: HomeWidgetLayoutFamily): Int = + when (layoutFamily) { + HomeWidgetLayoutFamily.CLASSIC -> HOME_WIDGET_SLOT_COUNT_CLASSIC + HomeWidgetLayoutFamily.RADIANT -> HOME_WIDGET_SLOT_COUNT_RADIANT + } + + private fun defaultHomeWidgetSlots(layoutFamily: HomeWidgetLayoutFamily): List = + when (layoutFamily) { + HomeWidgetLayoutFamily.CLASSIC -> + listOf("bathroom", "electricity") + List(HOME_WIDGET_SLOT_COUNT_CLASSIC - 2) { null } + HomeWidgetLayoutFamily.RADIANT -> listOf( + "electricity", + "bathroom", + "grade", + "exam", + "weather", + "network_recharge", + "free_classroom" + ) + } - private fun normalizeHomeWidgetSlots(slots: List): List { + private fun normalizeHomeWidgetSlots( + layoutFamily: HomeWidgetLayoutFamily, + slots: List + ): List { val seen = mutableSetOf() - return List(HOME_WIDGET_SLOT_COUNT) { index -> + return List(homeWidgetSlotCount(layoutFamily)) { index -> val id = slots.getOrNull(index)?.takeIf { it.isNotBlank() } if (id != null && seen.add(id)) id else null } } - fun getHomeWidgetSlots(): List { + fun getHomeWidgetSlots(): List = + getHomeWidgetSlots(HomeWidgetLayoutFamily.CLASSIC) + + fun getHomeWidgetSlots(layoutFamily: HomeWidgetLayoutFamily): List { val userId = getCurrentUser()?.xh homeWidgetSlotsCache - ?.takeIf { it.userId == userId } + ?.takeIf { it.userId == userId && it.layoutFamily == layoutFamily } ?.let { return it.slots } - val data = userGetStringOrMigrate(HOME_WIDGET_SLOTS_KEY) { - kv.decodeString(HOME_WIDGET_SLOTS_KEY) + val storageKey = homeWidgetSlotsKey(layoutFamily) + val data = userGetStringOrMigrate(storageKey) { + kv.decodeString(storageKey) } ?: "" val slots = if (data.isBlank()) { - defaultHomeWidgetSlots() + defaultHomeWidgetSlots(layoutFamily) } else { runCatching { Gson().fromJson>( @@ -490,26 +529,40 @@ object AHUCache { object : TypeToken>() {}.type ) }.getOrNull() - ?.let(::normalizeHomeWidgetSlots) - ?: defaultHomeWidgetSlots() + ?.let { normalizeHomeWidgetSlots(layoutFamily, it) } + ?: defaultHomeWidgetSlots(layoutFamily) } - homeWidgetSlotsCache = HomeWidgetSlotsCache(userId, slots) + homeWidgetSlotsCache = HomeWidgetSlotsCache(userId, layoutFamily, slots) return slots } /** 用户是否曾自定义主页插槽(true=已保存过布局,false=从未设置)。 */ - fun hasCustomHomeWidgetSlots(): Boolean { - val data = userGetStringOrMigrate(HOME_WIDGET_SLOTS_KEY) { - kv.decodeString(HOME_WIDGET_SLOTS_KEY) + fun hasCustomHomeWidgetSlots(): Boolean = + hasCustomHomeWidgetSlots(HomeWidgetLayoutFamily.CLASSIC) + + fun hasCustomHomeWidgetSlots(layoutFamily: HomeWidgetLayoutFamily): Boolean { + val storageKey = homeWidgetSlotsKey(layoutFamily) + val data = userGetStringOrMigrate(storageKey) { + kv.decodeString(storageKey) } ?: "" return data.isNotBlank() } - fun saveHomeWidgetSlots(slots: List) { - val normalizedSlots = normalizeHomeWidgetSlots(slots) + fun saveHomeWidgetSlots(slots: List) = + saveHomeWidgetSlots(HomeWidgetLayoutFamily.CLASSIC, slots) + + fun saveHomeWidgetSlots( + layoutFamily: HomeWidgetLayoutFamily, + slots: List + ) { + val normalizedSlots = normalizeHomeWidgetSlots(layoutFamily, slots) val data = Gson().toJson(normalizedSlots) - userPutString(HOME_WIDGET_SLOTS_KEY, data) - homeWidgetSlotsCache = HomeWidgetSlotsCache(getCurrentUser()?.xh, normalizedSlots) + userPutString(homeWidgetSlotsKey(layoutFamily), data) + homeWidgetSlotsCache = HomeWidgetSlotsCache( + getCurrentUser()?.xh, + layoutFamily, + normalizedSlots + ) } fun logout() { From d23e142bcee82c774b1e887e170e01c271f77730 Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Fri, 4 Sep 2026 23:40:11 +0800 Subject: [PATCH 12/29] fix(theme): migrate legacy UI style preference --- .../com/ahu/ahutong/data/dao/PreferencesManager.kt | 5 ++++- .../java/com/ahu/ahutong/data/model/AppUiTheme.kt | 12 +++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) 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 d181a185..3de49d4f 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 @@ -19,6 +19,7 @@ object PreferencesKeys { 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 UI_STYLE = stringPreferencesKey("ui_style") val USE_BUILT_IN_SECURE_PASSWORD_KEYBOARD = booleanPreferencesKey("use_built_in_secure_password_keyboard") val COURSE_REMINDER_ENABLED = booleanPreferencesKey("course_reminder_enabled") @@ -285,7 +286,8 @@ class PreferencesManager @Inject constructor(@param:ApplicationContext private v val appUiTheme: Flow = context.dataStore.data.map { prefs -> AppUiTheme.fromStorage( value = prefs[PreferencesKeys.UI_THEME], - legacyUseLiquidGlass = prefs[PreferencesKeys.USE_LIQUID_GLASS] + legacyUseLiquidGlass = prefs[PreferencesKeys.USE_LIQUID_GLASS], + legacyUiStyle = prefs[PreferencesKeys.UI_STYLE] ) } @@ -299,6 +301,7 @@ class PreferencesManager @Inject constructor(@param:ApplicationContext private v prefs.remove(PreferencesKeys.THEME_COLOR) } prefs.remove(PreferencesKeys.USE_LIQUID_GLASS) + prefs.remove(PreferencesKeys.UI_STYLE) } } 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 index a43adaae..3c96696b 100644 --- a/app/src/main/java/com/ahu/ahutong/data/model/AppUiTheme.kt +++ b/app/src/main/java/com/ahu/ahutong/data/model/AppUiTheme.kt @@ -10,8 +10,18 @@ enum class AppUiTheme(val storageValue: String, val displayName: String) { get() = this == LIQUID_GLASS || this == RADIANT companion object { - fun fromStorage(value: String?, legacyUseLiquidGlass: Boolean?): AppUiTheme = + fun fromStorage( + value: String?, + legacyUseLiquidGlass: Boolean?, + legacyUiStyle: String? = null + ): AppUiTheme = entries.firstOrNull { it.storageValue == value } + ?: when (legacyUiStyle) { + "original" -> MATERIAL + "liquid_glass" -> LIQUID_GLASS + "radiant_ui" -> RADIANT + else -> null + } ?: if (legacyUseLiquidGlass == false) MATERIAL else LIQUID_GLASS } } From a6530f021ef16c84e0eb629704c9cfd84c3887c7 Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Fri, 4 Sep 2026 23:46:06 +0800 Subject: [PATCH 13/29] fix(xuexiaotong): isolate and clean up reminders --- .../ahutong/data/xuexiaotong/ChaoxingApi.kt | 25 ++++---- .../com/ahu/ahutong/reminder/AlarmReceiver.kt | 7 ++- .../com/ahu/ahutong/reminder/BootReceiver.kt | 6 +- .../ahu/ahutong/reminder/ReminderScheduler.kt | 62 ++++++++++++++----- .../ui/screen/xuexiaotong/RemindDialog.kt | 8 ++- .../xuexiaotong/XuexiaotongViewModel.kt | 50 ++++++++++----- 6 files changed, 109 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/ChaoxingApi.kt b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/ChaoxingApi.kt index 30307aa8..c16e7996 100644 --- a/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/ChaoxingApi.kt +++ b/app/src/main/java/com/ahu/ahutong/data/xuexiaotong/ChaoxingApi.kt @@ -31,6 +31,7 @@ class ChaoxingApi(private val context: Context) { fun hasSession(): Boolean = Store.hasCookie() fun clearSession() { + client.dispatcher.cancelAll() Store.clearCookie() cookieJar.clear() } @@ -397,24 +398,22 @@ class ChaoxingApi(private val context: Context) { delay(600) for (work in works) { - var startTs: Long? = null - var endTs: Long? = null + val previous = existingByCourse[course.courseId]?.get(work.workId) + var startTs = previous?.startTs + var endTs = previous?.endTs // 每次同步都重新抓取截止时间,确保延期后的时间更新 try { val dl = fetchWorkDeadline(work) - if (dl != null) { startTs = dl.first; endTs = dl.second } + if (dl != null) { + startTs = dl.first + endTs = dl.second + } delay(800) - } catch (e: Exception) { - // 抓取失败时使用旧数据 - val prev = existingByCourse[course.courseId]?.get(work.workId) - startTs = prev?.startTs - endTs = prev?.endTs - } - - val prev = existingByCourse[course.courseId]?.get(work.workId) + } catch (_: Exception) { } + allWorks.add(work.copy(startTs = startTs, endTs = endTs, - rawStart = prev?.rawStart ?: "", rawEnd = prev?.rawEnd ?: "")) + rawStart = previous?.rawStart ?: "", rawEnd = previous?.rawEnd ?: "")) } } catch (e: Exception) { // 单门课程失败时保留旧数据,避免该课程作业从日历消失 @@ -479,4 +478,4 @@ class ChaoxingApi(private val context: Context) { listener?.onProgress(total, total, "同步完成") result } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/ahu/ahutong/reminder/AlarmReceiver.kt b/app/src/main/java/com/ahu/ahutong/reminder/AlarmReceiver.kt index 88a4e8c4..4713c018 100644 --- a/app/src/main/java/com/ahu/ahutong/reminder/AlarmReceiver.kt +++ b/app/src/main/java/com/ahu/ahutong/reminder/AlarmReceiver.kt @@ -8,14 +8,17 @@ import android.content.Intent class AlarmReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ReminderScheduler.ACTION_REMIND) return + val title = intent.getStringExtra(ReminderScheduler.EXTRA_TITLE) ?: "学习通日历" val content = intent.getStringExtra(ReminderScheduler.EXTRA_CONTENT) ?: "提醒" + val reminderKey = ReminderScheduler.reminderKey(intent) ReminderScheduler.ensureChannel(context) val manager = context.getSystemService(NotificationManager::class.java) manager.notify( - System.currentTimeMillis().hashCode(), + ReminderScheduler.notificationIdFor(reminderKey), ReminderScheduler.buildReminderNotification(context, title, content) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/ahu/ahutong/reminder/BootReceiver.kt b/app/src/main/java/com/ahu/ahutong/reminder/BootReceiver.kt index 95705a49..a7e2a852 100644 --- a/app/src/main/java/com/ahu/ahutong/reminder/BootReceiver.kt +++ b/app/src/main/java/com/ahu/ahutong/reminder/BootReceiver.kt @@ -13,9 +13,9 @@ class BootReceiver : BroadcastReceiver() { Intent.ACTION_MY_PACKAGE_REPLACED, "android.intent.action.TIME_SET", Intent.ACTION_TIMEZONE_CHANGED -> { - Store.saveRemindedMap(emptyMap()) - ReminderScheduler.scheduleAll(context) + Store.init(context.applicationContext) + ReminderScheduler.rescheduleAll(context) } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/ahu/ahutong/reminder/ReminderScheduler.kt b/app/src/main/java/com/ahu/ahutong/reminder/ReminderScheduler.kt index feefc8c0..452db325 100644 --- a/app/src/main/java/com/ahu/ahutong/reminder/ReminderScheduler.kt +++ b/app/src/main/java/com/ahu/ahutong/reminder/ReminderScheduler.kt @@ -14,8 +14,12 @@ import java.util.Calendar object ReminderScheduler { const val CHANNEL_ID = "ahutong_cx_reminder" + const val ACTION_REMIND = "com.ahu.ahutong.reminder.ACTION_REMIND_XUEXIAOTONG" const val EXTRA_TITLE = "extra_title" const val EXTRA_CONTENT = "extra_content" + private const val EXTRA_REMINDER_KEY = "extra_reminder_key" + private const val REQUEST_CODE_NAMESPACE = 0x40000000 + private const val REQUEST_CODE_MASK = 0x0fffffff fun ensureChannel(context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -49,7 +53,10 @@ object ReminderScheduler { fun scheduleAll(context: Context) { ensureChannel(context) val setting = Store.getRemindSetting() - if (!setting.enabled) return + if (!setting.enabled || !Store.hasCookie()) { + cancelAll(context) + return + } val now = System.currentTimeMillis() val works = Store.getWorks() @@ -98,22 +105,38 @@ object ReminderScheduler { fun cancelAll(context: Context) { val am = context.getSystemService(AlarmManager::class.java) - allReminderKeys().forEach { key -> - val pi = PendingIntent.getBroadcast( - context, key.hashCode(), - Intent(context, AlarmReceiver::class.java), - PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE - ) - pi?.let { - am.cancel(it) - it.cancel() + val keys = (Store.getRemindedMap().keys + allReminderKeys()).distinct() + keys.forEach { key -> + listOf( + requestCodeFor(key) to ACTION_REMIND, + key.hashCode() to null + ).forEach { (requestCode, action) -> + val intent = Intent(context, AlarmReceiver::class.java).apply { + if (action != null) this.action = action + } + PendingIntent.getBroadcast( + context, + requestCode, + intent, + PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE + )?.let { + am.cancel(it) + it.cancel() + } } } + Store.saveRemindedMap(emptyMap()) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = context.getSystemService(NotificationManager::class.java) + manager.activeNotifications + .filter { it.notification.channelId == CHANNEL_ID } + .forEach { manager.cancel(it.tag, it.id) } + } } fun rescheduleAll(context: Context) { cancelAll(context) - Store.saveRemindedMap(emptyMap()) scheduleAll(context) } @@ -130,12 +153,14 @@ object ReminderScheduler { return try { val alarmManager = context.getSystemService(AlarmManager::class.java) val intent = Intent(context, AlarmReceiver::class.java).apply { + action = ACTION_REMIND putExtra(EXTRA_TITLE, title) putExtra(EXTRA_CONTENT, content) + putExtra(EXTRA_REMINDER_KEY, key) } val pending = PendingIntent.getBroadcast( context, - key.hashCode(), + requestCodeFor(key), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) @@ -165,7 +190,7 @@ object ReminderScheduler { ensureChannel(context) val manager = context.getSystemService(NotificationManager::class.java) manager.notify( - System.currentTimeMillis().hashCode(), + notificationIdFor("test_${System.currentTimeMillis()}"), buildReminderNotification(context, "学习通日历", "这是一条测试通知") ) true @@ -175,7 +200,7 @@ object ReminderScheduler { fun buildReminderNotification(context: Context, title: String, content: String): android.app.Notification { ensureChannel(context) val launch = PendingIntent.getActivity( - context, 0, + context, REQUEST_CODE_NAMESPACE, context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } ?: Intent(), @@ -191,4 +216,11 @@ object ReminderScheduler { .setContentIntent(launch) .build() } -} \ No newline at end of file + + fun reminderKey(intent: Intent): String = intent.getStringExtra(EXTRA_REMINDER_KEY).orEmpty() + + fun notificationIdFor(key: String): Int = + REQUEST_CODE_NAMESPACE or (key.hashCode() and REQUEST_CODE_MASK) + + private fun requestCodeFor(key: String): Int = notificationIdFor(key) +} diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/RemindDialog.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/RemindDialog.kt index 8326531b..f1a4edc3 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/RemindDialog.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/RemindDialog.kt @@ -4,6 +4,7 @@ import android.Manifest import android.app.AlarmManager import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Build import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult @@ -194,7 +195,10 @@ fun RemindDialog( .clickable { try { context.startActivity( - Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM) + Intent( + Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, + Uri.parse("package:${context.packageName}") + ) ) } catch (_: Exception) { } } @@ -268,4 +272,4 @@ fun RemindDialog( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongViewModel.kt index 249dcc7c..c55fff50 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongViewModel.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/xuexiaotong/XuexiaotongViewModel.kt @@ -12,7 +12,9 @@ import com.ahu.ahutong.data.xuexiaotong.RemindSetting import com.ahu.ahutong.data.xuexiaotong.Store import com.ahu.ahutong.data.xuexiaotong.Work import com.ahu.ahutong.reminder.ReminderScheduler +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -77,6 +79,9 @@ class XuexiaotongViewModel(val api: ChaoxingApi, private val appContext: Context _works.value = Store.getWorks() _courses.value = Store.getCourses() _progress.value = Store.getCourseProgress() + _lastSync.value = Store.getLastSync() + _remindSetting.value = Store.getRemindSetting() + _customEvents.value = Store.getCustomEvents() _showDone.value = Store.getShowDone() _doneGray.value = Store.getDoneGray() _showEmptyCourses.value = Store.getShowEmptyCourses() @@ -107,18 +112,23 @@ class XuexiaotongViewModel(val api: ChaoxingApi, private val appContext: Context } fun logout() { + _loggedIn.value = false + viewModelScope.coroutineContext.cancelChildren() ReminderScheduler.cancelAll(appContext) api.clearSession() Store.clearLoginData() - Store.clearCredential() - _loggedIn.value = false _works.value = emptyList() _courses.value = emptyList() _progress.value = emptyList() + _lastSync.value = 0L + _syncProgress.value = SyncProgress() + _courseSyncProgress.value = SyncProgress() + _syncing.value = false + _courseSyncing.value = false } fun syncWorks() { - if (_syncing.value) return + if (!_loggedIn.value || _syncing.value || _courseSyncing.value) return viewModelScope.launch { _syncing.value = true _syncProgress.value = SyncProgress(message = "正在同步作业...") @@ -136,17 +146,23 @@ class XuexiaotongViewModel(val api: ChaoxingApi, private val appContext: Context _syncProgress.value = SyncProgress(message = "同步完成") Store.saveLastSync(System.currentTimeMillis()) _lastSync.value = Store.getLastSync() - } catch (e: Exception) { - _syncProgress.value = SyncProgress(message = e.message ?: "同步失败") + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + _syncProgress.value = SyncProgress(message = exception.message ?: "同步失败") } finally { _syncing.value = false - ReminderScheduler.scheduleAll(appContext) + if (_loggedIn.value) { + ReminderScheduler.rescheduleAll(appContext) + } else { + clearRemoteStateAfterLogout() + } } } } fun syncCourseProgress() { - if (_courseSyncing.value) return + if (!_loggedIn.value || _syncing.value || _courseSyncing.value) return viewModelScope.launch { _courseSyncing.value = true _courseSyncProgress.value = SyncProgress(message = "正在同步课程进度...") @@ -163,10 +179,13 @@ class XuexiaotongViewModel(val api: ChaoxingApi, private val appContext: Context _courseSyncProgress.value = SyncProgress(message = "同步完成") Store.saveLastSync(System.currentTimeMillis()) _lastSync.value = Store.getLastSync() - } catch (e: Exception) { - _courseSyncProgress.value = SyncProgress(message = e.message ?: "同步失败") + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + _courseSyncProgress.value = SyncProgress(message = exception.message ?: "同步失败") } finally { _courseSyncing.value = false + if (!_loggedIn.value) clearRemoteStateAfterLogout() } } } @@ -182,12 +201,9 @@ class XuexiaotongViewModel(val api: ChaoxingApi, private val appContext: Context } fun saveCustomEvents(list: List) { - // 先取消旧列表的所有提醒(cancelAll 从 Store 读取,必须在保存新列表之前) - ReminderScheduler.cancelAll(appContext) - Store.saveRemindedMap(emptyMap()) Store.saveCustomEvents(list) _customEvents.value = list - ReminderScheduler.scheduleAll(appContext) + ReminderScheduler.rescheduleAll(appContext) } fun addCustomEvent(ev: CustomEvent) { @@ -212,10 +228,16 @@ class XuexiaotongViewModel(val api: ChaoxingApi, private val appContext: Context saveCustomEvents(emptyList()) } + private fun clearRemoteStateAfterLogout() { + ReminderScheduler.cancelAll(appContext) + api.clearSession() + Store.clearLoginData() + } + class Factory(private val api: ChaoxingApi, private val appContext: Context) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { return XuexiaotongViewModel(api, appContext) as T } } -} \ No newline at end of file +} From 05507e7cb1def48aa684678288981d5739c8355d Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Fri, 4 Sep 2026 23:47:54 +0800 Subject: [PATCH 14/29] fix(navigation): restore radiant tools and home layouts --- .../com/ahu/ahutong/ui/screen/BottomNavBar.kt | 1 + .../java/com/ahu/ahutong/ui/screen/Main.kt | 4 +-- .../com/ahu/ahutong/ui/screen/main/Home.kt | 33 +++++++++---------- .../com/ahu/ahutong/ui/screen/main/Tools.kt | 18 +++++++--- 4 files changed, 30 insertions(+), 26 deletions(-) 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 45a5bd6d..36bff926 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 @@ -119,6 +119,7 @@ private fun BoxScope.RadiantBottomNavBar( val destinations = listOf( RadiantDestination("home", "主页", R.drawable.ic_nav_home), RadiantDestination("schedule", "课表", R.drawable.ic_nav_schedule), + RadiantDestination("tools", "小工具", R.drawable.ic_nav_tools), RadiantDestination( "xuexiaotong", if (showingSchedule) "日程" else "课程", 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 776127de..49657aed 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 @@ -152,9 +152,7 @@ fun Main( } LaunchedEffect(appUiTheme) { - if (appUiTheme == AppUiTheme.RADIANT && primaryRoute == "tools") { - selectPrimaryDestination("home") - } else if (appUiTheme != AppUiTheme.RADIANT && currentRoute == "xuexiaotong") { + if (appUiTheme != AppUiTheme.RADIANT && currentRoute == "xuexiaotong") { navController.navigate("home") { popUpTo("home") { inclusive = false } launchSingleTop = true 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 6e7c3a2e..1f38e333 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 @@ -57,6 +57,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.ahu.ahutong.BuildConfig import com.ahu.ahutong.R import com.ahu.ahutong.data.dao.AHUCache +import com.ahu.ahutong.data.dao.HomeWidgetLayoutFamily import com.ahu.ahutong.data.schedule.CurrentWeekResolver import androidx.navigation.NavHostController import com.ahu.ahutong.data.debug.DebugClock @@ -148,6 +149,11 @@ fun Home( } } val radiant = isRadiantUi + val layoutFamily = if (radiant) { + HomeWidgetLayoutFamily.RADIANT + } else { + HomeWidgetLayoutFamily.CLASSIC + } val slotCount = if (radiant) { HomeWidgetRegistry.slotCountRadiant } else { @@ -164,13 +170,14 @@ fun Home( ) } var isEditingHome by remember { mutableStateOf(false) } - var homeWidgetSlots by remember(radiant) { - val initialSlots = if (radiant && !AHUCache.hasCustomHomeWidgetSlots()) { - HomeWidgetRegistry.defaultSlotsRadiant - } else { - listOf("bathroom", "electricity") - } - mutableStateOf(normalizeHomeWidgetSlots(initialSlots, slotCount, knownWidgetIds)) + var homeWidgetSlots by remember(layoutFamily) { + mutableStateOf( + normalizeHomeWidgetSlots( + AHUCache.getHomeWidgetSlots(layoutFamily), + slotCount, + knownWidgetIds + ) + ) } val slotBounds = remember { mutableStateMapOf() } var libraryBounds by remember { mutableStateOf(null) } @@ -196,7 +203,7 @@ fun Home( fun saveHomeWidgetSlots(slots: List) { val normalizedSlots = normalizeHomeWidgetSlots(slots, slotCount, knownWidgetIds) homeWidgetSlots = normalizedSlots - AHUCache.saveHomeWidgetSlots(normalizedSlots) + AHUCache.saveHomeWidgetSlots(layoutFamily, normalizedSlots) } fun enterHomeEditMode() { @@ -310,16 +317,6 @@ fun Home( exitHomeEditMode() } - LaunchedEffect(radiant) { - homeWidgetSlots = withContext(Dispatchers.IO) { - val savedSlots = if (radiant && !AHUCache.hasCustomHomeWidgetSlots()) { - HomeWidgetRegistry.defaultSlotsRadiant - } else { - AHUCache.getHomeWidgetSlots() - } - normalizeHomeWidgetSlots(savedSlots, slotCount, knownWidgetIds) - } - } LaunchedEffect(Unit) { if (!enterEditModeRequest) { exitHomeEditMode() 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 033f98e8..1f70eb9f 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 @@ -65,6 +65,7 @@ import coil.compose.AsyncImage import coil.request.ImageRequest import com.ahu.ahutong.data.AHURepository import com.ahu.ahutong.data.dao.AHUCache +import com.ahu.ahutong.data.dao.HomeWidgetLayoutFamily import com.ahu.ahutong.utils.FileUtils import com.ahu.ahutong.R import com.ahu.ahutong.appwidget.ScheduleAppWidgetReceiver @@ -72,6 +73,7 @@ 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.components.isRadiantUi import com.ahu.ahutong.ui.screen.main.home.HomeWidgetRegistry import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.kyant.capsule.ContinuousCapsule @@ -94,15 +96,21 @@ fun Tools( ) { val context = LocalContext.current val scope = rememberCoroutineScope() - var homeWidgetIds by remember { - mutableStateOf(AHUCache.getHomeWidgetSlots().filterNotNull().toSet()) + val radiant = isRadiantUi + val layoutFamily = if (radiant) { + HomeWidgetLayoutFamily.RADIANT + } else { + HomeWidgetLayoutFamily.CLASSIC + } + var homeWidgetIds by remember(layoutFamily) { + mutableStateOf(AHUCache.getHomeWidgetSlots(layoutFamily).filterNotNull().toSet()) } fun refreshHomeWidgetIds() { - homeWidgetIds = AHUCache.getHomeWidgetSlots().filterNotNull().toSet() + homeWidgetIds = AHUCache.getHomeWidgetSlots(layoutFamily).filterNotNull().toSet() } - LaunchedEffect(navController) { + LaunchedEffect(navController, layoutFamily) { refreshHomeWidgetIds() navController.currentBackStackEntryFlow.collect { backStackEntry -> if (backStackEntry.destination.route == "tools") { @@ -154,7 +162,7 @@ fun Tools( .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - HomeWidgetRegistry.widgets + HomeWidgetRegistry.availableWidgets(radiant) .filter { it.id !in homeWidgetIds } .forEach { widget -> ToolItem( From f679b9b2e67fe44c658f346feafb68e8da9e8ee0 Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Fri, 4 Sep 2026 23:48:55 +0800 Subject: [PATCH 15/29] fix(weather): unify settings sheet container --- .../com/ahu/ahutong/ui/screen/main/Weather.kt | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) 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 5714fa7a..b2ade47e 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 @@ -42,6 +42,7 @@ 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.AppModalBottomSheet import com.ahu.ahutong.ui.components.AppScrollablePageLayout import com.ahu.ahutong.ui.components.AppSearchField import com.ahu.ahutong.ui.components.AppToggle @@ -221,32 +222,16 @@ fun Weather( if (showSettings) { val config = weatherViewModel.homeConfig - val sheetShape = BottomSheetDefaults.ExpandedShape - ModalBottomSheet( - onDismissRequest = { showSettings = false }, - modifier = Modifier.appLiquidGlassSurface( - shape = sheetShape, - fallbackColor = 100.n1 withNight 15.n1, - level = LiquidGlassSurfaceLevel.Floating, - backdropSamplingEnabled = false - ), - shape = sheetShape, - containerColor = Color.Transparent, - tonalElevation = 0.dp + AppModalBottomSheet( + title = "天气设置", + onDismissRequest = { showSettings = false } ) { Column( modifier = Modifier .fillMaxWidth() - .navigationBarsPadding() .padding(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - "天气设置", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = 0.n1 withNight 100.n1 - ) Text( "选择首页天气样式和详细卡片信息:", style = MaterialTheme.typography.bodyMedium, From 88cf5647a99b5395ba3f271dfebcb602b0840458 Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Fri, 4 Sep 2026 23:55:38 +0800 Subject: [PATCH 16/29] fix(payment): preserve secure flows in radiant pages --- .../ahutong/ui/screen/main/BathroomDeposit.kt | 128 ++++++++++--- .../ui/screen/main/CardBalanceDeposit.kt | 104 ++++++++--- .../ui/screen/main/ElectricityDeposit.kt | 169 +++++++++++++----- .../ahutong/ui/screen/main/NetworkRecharge.kt | 131 +++++++++++++- 4 files changed, 421 insertions(+), 111 deletions(-) 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 88a4aa10..4870d58f 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 @@ -3,6 +3,7 @@ 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.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -41,8 +42,11 @@ 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.GlassCard +import com.ahu.ahutong.ui.components.SecondaryPageScaffold import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.state.BathroomDepositViewModel import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import com.kyant.monet.n1 @@ -103,15 +107,10 @@ fun BathroomDeposit( val balanceData = info?.data?.map?.showData val canSubmit = amount.toDoubleOrNull()?.let { it > 0.0 } == true && accountData != null && payState !is PayState.InProgress + val horizontalPadding = if (isRadiantUi) 0.dp else 16.dp - AppScrollablePageLayout( - title = "浴室缴费", - onBack = onBack, - modifier = Modifier - .fillMaxSize() - .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), - bottomPadding = 48.dp - ) { + val pageContent: @Composable ColumnScope.() -> Unit = { + val lookupContent: @Composable ColumnScope.() -> Unit = { AppSelectField( label = "浴室", selected = bathroom, @@ -120,7 +119,7 @@ fun BathroomDeposit( bathroom = selected viewmodel.clearBathroomInfo() }, - modifier = Modifier.padding(horizontal = 16.dp), + modifier = Modifier.padding(horizontal = horizontalPadding), miuixInsideMargin = androidx.compose.foundation.layout.PaddingValues( start = 12.dp, top = 16.dp, @@ -134,7 +133,7 @@ fun BathroomDeposit( Column( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = horizontalPadding) .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -189,19 +188,27 @@ fun BathroomDeposit( } } - 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) + } + if (isRadiantUi) { + GlassCard( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth() ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + content = lookupContent + ) + } + } else { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + content = lookupContent + ) + } + + AnimatedVisibility(visible = isQuerying || info != null) { + val accountContent: @Composable ColumnScope.() -> Unit = { Text("浴室账户", style = MaterialTheme.typography.titleMedium) if (isQuerying) { Row( @@ -232,14 +239,35 @@ fun BathroomDeposit( ) } } + if (isRadiantUi) { + GlassCard( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + content = accountContent + ) + } + } else { + Column( + modifier = Modifier + .padding(horizontal = horizontalPadding) + .fillMaxWidth() + .appLiquidGlassSurface( + shape = AppComponentTokens.CardShape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainerHigh, + level = LiquidGlassSurfaceLevel.Control + ) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + content = accountContent + ) + } } - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { + val amountContent: @Composable ColumnScope.() -> Unit = { Text( text = "缴费金额", style = MaterialTheme.typography.titleMedium, @@ -261,10 +289,30 @@ fun BathroomDeposit( keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) ) } + if (isRadiantUi) { + GlassCard( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + content = amountContent + ) + } + } else { + Column( + modifier = Modifier + .padding(horizontal = horizontalPadding) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + content = amountContent + ) + } Column( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = horizontalPadding) .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -300,6 +348,28 @@ fun BathroomDeposit( } } + if (isRadiantUi) { + SecondaryPageScaffold( + title = "浴室缴费", + modifier = Modifier.fillMaxSize() + ) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + content = pageContent + ) + } + } else { + AppScrollablePageLayout( + title = "浴室缴费", + onBack = onBack, + modifier = Modifier + .fillMaxSize() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), + bottomPadding = 48.dp, + content = pageContent + ) + } + if (showPasswordDialog) { SecurePaymentPasswordDialog( password = password, 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 d989895e..ec5fd501 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 @@ -9,6 +9,7 @@ import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -53,9 +54,12 @@ 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.GlassCard import com.ahu.ahutong.ui.components.LocalAppUiTheme +import com.ahu.ahutong.ui.components.SecondaryPageScaffold import com.ahu.ahutong.ui.components.SettingsChoice import com.ahu.ahutong.ui.components.SettingsSelectRow +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.state.CardAccountState import com.ahu.ahutong.ui.state.CardBalanceDepositViewModel import com.ahu.ahutong.ui.state.PaymentState @@ -155,15 +159,9 @@ fun CardBalanceDeposit( } null -> false } - - AppScrollablePageLayout( - title = "校园卡充值", - onBack = { navController.popBackStack() }, - modifier = Modifier - .fillMaxSize() - .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), - bottomPadding = 48.dp - ) { + val horizontalPadding = if (isRadiantUi) 0.dp else 16.dp + + val pageContent: @Composable ColumnScope.() -> Unit = { if (LocalAppUiTheme.current != AppUiTheme.MATERIAL) { AppSelectField( label = "充值方式", @@ -172,7 +170,7 @@ fun CardBalanceDeposit( AppSelectOption(method, method.displayName) }, onSelected = ::selectRechargeBank, - modifier = Modifier.padding(horizontal = 16.dp), + modifier = Modifier.padding(horizontal = horizontalPadding), enabled = paymentState != PaymentState.Loading, valueTextAlign = TextAlign.End, miuixInsideMargin = androidx.compose.foundation.layout.PaddingValues( @@ -185,16 +183,7 @@ fun CardBalanceDeposit( ) } - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .appLiquidGlassSurface( - shape = AppComponentTokens.CardShape, - fallbackColor = 100.n1 withNight 20.n1, - level = LiquidGlassSurfaceLevel.Panel - ) - ) { + val accountContent: @Composable ColumnScope.() -> Unit = { if (LocalAppUiTheme.current == AppUiTheme.MATERIAL) { SettingsSelectRow( title = "充值方式", @@ -260,16 +249,31 @@ fun CardBalanceDeposit( style = MaterialTheme.typography.titleMedium ) } - - } - - if (selectedRechargeBank != CardRechargeBank.ALIPAY) { + } + + if (isRadiantUi) { + GlassCard( + containerColor = 100.n1 withNight 20.n1, + modifier = Modifier.fillMaxWidth() + ) { + Column(content = accountContent) + } + } else { Column( modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { + .padding(horizontal = horizontalPadding) + .fillMaxWidth() + .appLiquidGlassSurface( + shape = AppComponentTokens.CardShape, + fallbackColor = 100.n1 withNight 20.n1, + level = LiquidGlassSurfaceLevel.Panel + ), + content = accountContent + ) + } + + if (selectedRechargeBank != CardRechargeBank.ALIPAY) { + val amountContent: @Composable ColumnScope.() -> Unit = { Text( text = "充值金额", style = MaterialTheme.typography.titleMedium, @@ -299,12 +303,32 @@ fun CardBalanceDeposit( ) ) } + if (isRadiantUi) { + GlassCard( + containerColor = 100.n1 withNight 20.n1, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + content = amountContent + ) + } + } else { + Column( + modifier = Modifier + .padding(horizontal = horizontalPadding) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + content = amountContent + ) + } } Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp), + .padding(horizontal = horizontalPadding), verticalArrangement = Arrangement.spacedBy(12.dp) ) { if (selectedRechargeBank == CardRechargeBank.ALIPAY) { @@ -416,6 +440,28 @@ fun CardBalanceDeposit( } + if (isRadiantUi) { + SecondaryPageScaffold( + title = "校园卡充值", + modifier = Modifier.fillMaxSize() + ) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + content = pageContent + ) + } + } else { + AppScrollablePageLayout( + title = "校园卡充值", + onBack = { navController.popBackStack() }, + modifier = Modifier + .fillMaxSize() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), + bottomPadding = 48.dp, + content = pageContent + ) + } + } private val CardRechargeBank.displayName: String 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 9cae7ec6..586375b9 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 @@ -3,6 +3,7 @@ package com.ahu.ahutong.ui.screen.main import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -40,8 +41,11 @@ 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.GlassCard +import com.ahu.ahutong.ui.components.SecondaryPageScaffold import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.state.CampusDataItem import com.ahu.ahutong.ui.state.ElectricityDepositViewModel import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel @@ -103,28 +107,11 @@ fun ElectricityDeposit( selectedBuilding != null && selectedFloor != null && selectedRoom != null && amount.toDoubleOrNull()?.let { it > 0.0 } == true && !isLoading && payState is PayState.Idle + val horizontalPadding = if (isRadiantUi) 0.dp else 16.dp - AppScrollablePageLayout( - title = "电控缴费", - onBack = onBack, - modifier = Modifier - .fillMaxSize() - .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), - bottomPadding = 48.dp - ) { + val pageContent: @Composable ColumnScope.() -> Unit = { if (errorMessage != null) { - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .appLiquidGlassSurface( - shape = AppComponentTokens.CardShape, - fallbackColor = MaterialTheme.colorScheme.errorContainer, - level = LiquidGlassSurfaceLevel.Panel - ) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { + val errorContent: @Composable ColumnScope.() -> Unit = { Text("电控信息加载失败", style = MaterialTheme.typography.titleMedium) Text( text = errorMessage.orEmpty(), @@ -139,13 +126,40 @@ fun ElectricityDeposit( Text("重新加载") } } + if (isRadiantUi) { + GlassCard( + containerColor = MaterialTheme.colorScheme.errorContainer, + overlayColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.24f), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + content = errorContent + ) + } + } else { + Column( + modifier = Modifier + .padding(horizontal = horizontalPadding) + .fillMaxWidth() + .appLiquidGlassSurface( + shape = AppComponentTokens.CardShape, + fallbackColor = MaterialTheme.colorScheme.errorContainer, + level = LiquidGlassSurfaceLevel.Panel + ) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + content = errorContent + ) + } } if (historyOptions.isNotEmpty()) { AppButton( onClick = onOpenRecentRooms, modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = horizontalPadding) .fillMaxWidth(), enabled = !isLoading, variant = AppButtonVariant.Secondary @@ -162,10 +176,7 @@ fun ElectricityDeposit( selectedRoom == null -> ElectricitySelectorLevel.Room else -> ElectricitySelectorLevel.Room } - Column( - modifier = Modifier.padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { + val selectorContent: @Composable ColumnScope.() -> Unit = { AppSelectField( label = "电控入口", selected = selectedController, @@ -218,20 +229,27 @@ fun ElectricityDeposit( loading = loadingSelector == ElectricitySelectorLevel.Room ) } + if (isRadiantUi) { + GlassCard( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + content = selectorContent + ) + } + } else { + Column( + modifier = Modifier.padding(horizontal = horizontalPadding), + verticalArrangement = Arrangement.spacedBy(12.dp), + content = selectorContent + ) + } roomInfo?.takeIf(String::isNotBlank)?.let { info -> - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .appLiquidGlassSurface( - shape = AppComponentTokens.CardShape, - fallbackColor = MaterialTheme.colorScheme.surfaceContainer, - level = LiquidGlassSurfaceLevel.Panel - ) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { + val roomContent: @Composable ColumnScope.() -> Unit = { Text( text = "房间信息", style = MaterialTheme.typography.titleMedium, @@ -243,14 +261,35 @@ fun ElectricityDeposit( style = MaterialTheme.typography.bodyLarge ) } + if (isRadiantUi) { + GlassCard( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + content = roomContent + ) + } + } else { + Column( + modifier = Modifier + .padding(horizontal = horizontalPadding) + .fillMaxWidth() + .appLiquidGlassSurface( + shape = AppComponentTokens.CardShape, + fallbackColor = MaterialTheme.colorScheme.surfaceContainer, + level = LiquidGlassSurfaceLevel.Panel + ) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + content = roomContent + ) + } } - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { + val amountContent: @Composable ColumnScope.() -> Unit = { Text( text = "缴费金额", style = MaterialTheme.typography.titleMedium, @@ -273,10 +312,30 @@ fun ElectricityDeposit( keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) ) } + if (isRadiantUi) { + GlassCard( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + content = amountContent + ) + } + } else { + Column( + modifier = Modifier + .padding(horizontal = horizontalPadding) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + content = amountContent + ) + } Column( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = horizontalPadding) .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -311,6 +370,28 @@ fun ElectricityDeposit( } } + if (isRadiantUi) { + SecondaryPageScaffold( + title = "电控缴费", + modifier = Modifier.fillMaxSize() + ) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + content = pageContent + ) + } + } else { + AppScrollablePageLayout( + title = "电控缴费", + onBack = onBack, + modifier = Modifier + .fillMaxSize() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), + bottomPadding = 48.dp, + content = pageContent + ) + } + if (showPasswordDialog) { SecurePaymentPasswordDialog( password = password, 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 f191f348..232751f6 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 @@ -3,6 +3,7 @@ package com.ahu.ahutong.ui.screen.main import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -40,6 +41,9 @@ 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.components.GlassCard +import com.ahu.ahutong.ui.components.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.component.SecurePaymentPasswordDialog import com.ahu.ahutong.ui.state.NetworkRechargePageState @@ -87,13 +91,7 @@ fun NetworkRecharge( } } - AppScrollablePageLayout( - title = "网费充值", - onBack = onBack, - modifier = Modifier - .fillMaxSize() - .appLiquidGlassSceneBackground(96.n1 withNight 10.n1) - ) { + val pageContent: @Composable ColumnScope.() -> Unit = { when (val state = pageState) { NetworkRechargePageState.Loading -> { LoadingCard() @@ -156,6 +154,27 @@ fun NetworkRecharge( } } + if (isRadiantUi) { + SecondaryPageScaffold( + title = "网费充值", + modifier = Modifier.fillMaxSize() + ) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + content = pageContent + ) + } + } else { + AppScrollablePageLayout( + title = "网费充值", + onBack = onBack, + modifier = Modifier + .fillMaxSize() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), + content = pageContent + ) + } + if (showDialog) { SecurePaymentPasswordDialog( password = password, @@ -189,6 +208,22 @@ private const val PAYMENT_RESULT_DISPLAY_DURATION_MS = 3_000L @Composable private fun LoadingCard() { + if (isRadiantUi) { + GlassCard( + containerColor = 100.n1 withNight 20.n1, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + AppCircularProgressIndicator() + } + } + return + } Box( modifier = Modifier .padding(horizontal = 16.dp) @@ -210,6 +245,27 @@ private fun ErrorCard( message: String, onRetry: () -> Unit ) { + if (isRadiantUi) { + GlassCard( + containerColor = 100.n1 withNight 20.n1, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = message, + color = 10.n1 withNight 90.n1, + style = MaterialTheme.typography.bodyLarge + ) + AppButton(onClick = onRetry, variant = AppButtonVariant.Secondary) { + Text("重试") + } + } + } + return + } Column( modifier = Modifier .padding(horizontal = 16.dp) @@ -237,6 +293,15 @@ private fun ErrorCard( private fun NetworkAccountCard( data: NetworkRechargeUiData ) { + if (isRadiantUi) { + GlassCard( + containerColor = 100.n1 withNight 20.n1, + modifier = Modifier.fillMaxWidth() + ) { + NetworkAccountContent(data = data) + } + return + } Column( modifier = Modifier .padding(horizontal = 16.dp) @@ -246,7 +311,15 @@ private fun NetworkAccountCard( fallbackColor = 100.n1 withNight 20.n1, level = LiquidGlassSurfaceLevel.Panel ) - .padding(20.dp), + ) { + NetworkAccountContent(data = data) + } +} + +@Composable +private fun NetworkAccountContent(data: NetworkRechargeUiData) { + Column( + modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( @@ -297,6 +370,23 @@ private fun AmountCard( onQuickAmountClick: (String) -> Unit, onDone: () -> Unit ) { + if (isRadiantUi) { + GlassCard( + containerColor = 100.n1 withNight 20.n1, + modifier = Modifier.fillMaxWidth() + ) { + AmountCardContent( + amount = amount, + amountError = amountError, + quickAmounts = quickAmounts, + maxAmount = maxAmount, + onAmountChange = onAmountChange, + onQuickAmountClick = onQuickAmountClick, + onDone = onDone + ) + } + return + } Column( modifier = Modifier .padding(horizontal = 16.dp) @@ -307,6 +397,29 @@ private fun AmountCard( level = LiquidGlassSurfaceLevel.Panel ) ) { + AmountCardContent( + amount = amount, + amountError = amountError, + quickAmounts = quickAmounts, + maxAmount = maxAmount, + onAmountChange = onAmountChange, + onQuickAmountClick = onQuickAmountClick, + onDone = onDone + ) + } +} + +@Composable +private fun AmountCardContent( + amount: String, + amountError: String?, + quickAmounts: List, + maxAmount: String?, + onAmountChange: (String) -> Unit, + onQuickAmountClick: (String) -> Unit, + onDone: () -> Unit +) { + Column { Text( text = "充值金额", modifier = Modifier.padding(16.dp), @@ -365,7 +478,7 @@ private fun RechargeActionRow( Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp), + .padding(horizontal = if (isRadiantUi) 0.dp else 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { when (payState) { From 5f4ba0dfc1b473f2783c5bf4e9c1e206ad4f2963 Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Sat, 5 Sep 2026 00:09:53 +0800 Subject: [PATCH 17/29] feat(radiant): adapt business pages --- .../ahu/ahutong/ui/screen/main/Evaluation.kt | 202 ++++++++++--- .../com/ahu/ahutong/ui/screen/main/Exam.kt | 183 ++++++++++-- .../ahutong/ui/screen/main/FreeClassroom.kt | 42 ++- .../com/ahu/ahutong/ui/screen/main/Grade.kt | 266 ++++++++++++++---- .../ahu/ahutong/ui/screen/main/LostFound.kt | 143 +++++++--- .../ahu/ahutong/ui/screen/main/PhoneBook.kt | 109 +++++-- .../ahu/ahutong/ui/screen/main/Repository.kt | 147 +++++++--- .../ahu/ahutong/ui/screen/main/Schedule.kt | 243 +++++++++++++--- .../ui/screen/main/schedule/CourseCard.kt | 48 +++- 9 files changed, 1116 insertions(+), 267 deletions(-) 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 dd3ed48a..786a7cd7 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 @@ -1,6 +1,7 @@ package com.ahu.ahutong.ui.screen.main import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -19,6 +20,7 @@ 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.LazyListScope import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -29,6 +31,8 @@ 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.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import com.ahu.ahutong.ui.components.AppCircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -53,13 +57,16 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel +import com.ahu.ahutong.R import com.ahu.ahutong.data.model.EvalQuestion import com.ahu.ahutong.data.model.EvalTask import com.ahu.ahutong.data.model.EvalTeacher @@ -73,6 +80,8 @@ 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.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.EvaluationViewModel import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel @@ -96,6 +105,10 @@ fun Evaluation( val presetActionMessage by viewModel.presetActionMessage.collectAsState() val currentTask by viewModel.currentTask.collectAsState() + BackHandler(enabled = isRadiantUi && currentTask != null) { + viewModel.backToList() + } + LaunchedEffect(errorMessage) { errorMessage?.let { Toast.makeText(context, it, Toast.LENGTH_LONG).show() @@ -132,6 +145,7 @@ private fun EvaluationListScreen( var presetDialogShown by remember { mutableStateOf(false) } var confirmBulkSubmitShown by remember { mutableStateOf(false) } + var semesterExpanded by remember { mutableStateOf(false) } val presetTargetCount = remember(taskItems) { taskItems.sumOf { item -> item.taskList.sumOf { task -> @@ -199,24 +213,9 @@ private fun EvaluationListScreen( ) } - AppLazyPageLayout( - title = "评教", - onBack = onBack, - modifier = Modifier - .fillMaxSize() - .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 } - ) - } - ) { - item(key = "semester") { + val radiant = isRadiantUi + val pageContent: LazyListScope.() -> Unit = { + if (!radiant) item(key = "semester") { AppSelectField( label = "选择学期", selected = selectedSemesterId, @@ -348,6 +347,105 @@ private fun EvaluationListScreen( } } } + + if (radiant) { + SecondaryPageScaffold( + title = "评教", + subtitle = semesters.firstOrNull { it.id == selectedSemesterId }?.nameZh, + contentEdgeToEdge = true, + trailingContent = { + Box { + EvaluationRadiantTitleButton( + icon = R.drawable.ic_filter, + contentDescription = "选择学期", + onClick = { semesterExpanded = true } + ) + DropdownMenu( + expanded = semesterExpanded, + onDismissRequest = { semesterExpanded = false }, + containerColor = 100.n1 withNight 20.n1 + ) { + semesters.forEach { semester -> + DropdownMenuItem( + text = { Text(semester.nameZh) }, + 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 + ) + } + } + } + EvaluationRadiantTitleButton( + icon = R.drawable.ic_config, + contentDescription = "评教预设", + onClick = { presetDialogShown = true } + ) + } + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding(), + contentPadding = PaddingValues(top = 76.dp, bottom = 48.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + content = pageContent + ) + } + } else { + AppLazyPageLayout( + title = "评教", + onBack = onBack, + modifier = Modifier + .fillMaxSize() + .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 } + ) + }, + content = pageContent + ) + } +} + +@Composable +private fun EvaluationRadiantTitleButton( + icon: Int, + contentDescription: String, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center + ) { + IconButton(onClick = onClick) { + Icon( + painter = painterResource(icon), + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp) + ) + } + } } @Composable @@ -366,7 +464,7 @@ private fun EvaluationCard( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), - shape = SmoothRoundedCornerShape(20.dp), + shape = SmoothRoundedCornerShape(if (isRadiantUi) 16.dp else 20.dp), enabled = !reviewed && task.timeStatus, onClick = onClick ) { @@ -472,24 +570,9 @@ private fun EvaluationFormScreen(viewModel: EvaluationViewModel) { } } - AppLazyPageLayout( - title = currentCourseName.ifBlank { "课程评教" }, - onBack = { viewModel.backToList() }, - modifier = Modifier - .fillMaxSize() - .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 } - ) - } - ) { - item(key = "teacher") { + val radiant = isRadiantUi + val pageContent: LazyListScope.() -> Unit = { + if (!radiant) item(key = "teacher") { Text( text = "${currentTeacher?.teacherName.orEmpty()} · $currentLessonName", modifier = Modifier.padding(horizontal = 20.dp), @@ -582,6 +665,49 @@ private fun EvaluationFormScreen(viewModel: EvaluationViewModel) { } } } + + if (radiant) { + SecondaryPageScaffold( + title = currentCourseName.ifBlank { "课程评教" }, + subtitle = "${currentTeacher?.teacherName.orEmpty()} · $currentLessonName", + contentEdgeToEdge = true, + trailingContent = { + EvaluationRadiantTitleButton( + icon = R.drawable.ic_config, + contentDescription = "评教预设", + onClick = { presetDialogShown = true } + ) + } + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding(), + contentPadding = PaddingValues(top = 76.dp, bottom = 48.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + content = pageContent + ) + } + } else { + AppLazyPageLayout( + title = currentCourseName.ifBlank { "课程评教" }, + onBack = { viewModel.backToList() }, + modifier = Modifier + .fillMaxSize() + .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 } + ) + }, + content = pageContent + ) + } } @Composable @@ -822,7 +948,7 @@ private fun QuestionCard( AppCard( modifier = Modifier.fillMaxWidth(), - shape = SmoothRoundedCornerShape(20.dp) + shape = SmoothRoundedCornerShape(if (isRadiantUi) 16.dp else 20.dp) ) { Column( verticalArrangement = Arrangement.spacedBy(12.dp) 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 f9f09eea..555a299c 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 @@ -8,6 +8,7 @@ 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.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -54,6 +56,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -67,6 +70,10 @@ 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.components.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.SecondarySearchState +import com.ahu.ahutong.ui.components.GlassCard +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.ExamViewModel import com.ahu.ahutong.ui.state.RefreshState @@ -140,24 +147,19 @@ fun Exam( exam.orEmpty() } - AppScrollablePageLayout( - title = stringResource(id = R.string.exam), - onBack = onBack, - modifier = Modifier - .fillMaxSize() - .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), - bottomPadding = 48.dp, - actions = { RefreshButton(examViewModel) } - ) { - AppSearchField( - value = searchQuery, - onValueChange = { - searchQuery = it - isSearchActive = it.isNotBlank() - }, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - placeholder = "搜索课程名称…" - ) + val radiant = isRadiantUi + val pageContent: @Composable ColumnScope.() -> Unit = { + if (!radiant) { + AppSearchField( + value = searchQuery, + onValueChange = { + searchQuery = it + isSearchActive = it.isNotBlank() + }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + placeholder = "搜索课程名称…" + ) + } if (isLoading != true) { if (!filteredExams.isNullOrEmpty()) { @@ -264,6 +266,112 @@ fun Exam( } } } + + if (radiant) { + SecondaryPageScaffold( + title = stringResource(id = R.string.exam), + search = SecondarySearchState( + query = searchQuery, + visible = isSearchActive, + placeholder = "搜索课程名称…", + onQueryChange = { searchQuery = it }, + onClose = { + isSearchActive = false + searchQuery = "" + }, + onSubmit = {} + ), + trailingContent = { + ExamRadiantTitleButton( + icon = R.drawable.ic_find, + contentDescription = "搜索", + onClick = { isSearchActive = true } + ) + ExamRadiantRefreshButton(examViewModel) + } + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(24.dp), + content = pageContent + ) + } + } else { + AppScrollablePageLayout( + title = stringResource(id = R.string.exam), + onBack = onBack, + modifier = Modifier + .fillMaxSize() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), + bottomPadding = 48.dp, + actions = { RefreshButton(examViewModel) }, + content = pageContent + ) + } +} + +@Composable +private fun ExamRadiantTitleButton( + icon: Int, + contentDescription: String, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center + ) { + IconButton(onClick = onClick) { + Icon( + painter = painterResource(icon), + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp) + ) + } + } +} + +@Composable +private fun ExamRadiantRefreshButton(examViewModel: ExamViewModel) { + val behaviorReporter = rememberBehaviorActionReporter() + val refreshState by examViewModel.refreshState.collectAsState() + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center + ) { + IconButton( + enabled = refreshState != RefreshState.LOADING, + onClick = { + behaviorReporter.organic(AppActionId.MANUAL_REFRESH_EXAM) + examViewModel.loadExam(isRefresh = true) + } + ) { + when (refreshState) { + RefreshState.LOADING -> AppCircularProgressIndicator( + size = 18.dp, + strokeWidth = 2.dp + ) + RefreshState.UPDATED -> Icon( + imageVector = Icons.Default.Check, + contentDescription = "已更新", + tint = Color(0xFF2E7D32), + modifier = Modifier.size(18.dp) + ) + RefreshState.IDLE -> Icon( + painter = painterResource(R.drawable.ic_refresh), + contentDescription = "刷新考试", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp) + ) + } + } + } } /** "磬苑校区-博学楼-博学楼A101" → "磬苑校区 博学楼A101" */ @@ -290,17 +398,7 @@ private fun ExamCard( else -> Color(0xFFC62828) } - Column( - modifier = Modifier - .fillMaxWidth() - .appLiquidGlassSurface( - shape = SmoothRoundedCornerShape(20.dp), - fallbackColor = MaterialTheme.colorScheme.surfaceContainer, - level = LiquidGlassSurfaceLevel.Panel - ) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { + val cardContent: @Composable ColumnScope.() -> Unit = { // Course name + status badge Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -355,6 +453,33 @@ private fun ExamCard( Text("座位号:${examItem.seatNum}", color = 50.n1 withNight 80.n1, style = MaterialTheme.typography.bodyMedium) } } + + if (isRadiantUi) { + GlassCard( + modifier = Modifier.fillMaxWidth(), + containerColor = MaterialTheme.colorScheme.surfaceContainer, + shape = SmoothRoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + content = cardContent + ) + } + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(20.dp), + fallbackColor = MaterialTheme.colorScheme.surfaceContainer, + level = LiquidGlassSurfaceLevel.Panel + ) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + content = cardContent + ) + } } @Composable 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 36107460..8eba183f 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 @@ -17,6 +17,8 @@ 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.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ExpandLess @@ -55,6 +57,8 @@ 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.components.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.FreeClassroomViewModel import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel @@ -120,16 +124,7 @@ fun FreeClassroom( ) } - AppLazyPageLayout( - title = stringResource(id = R.string.free_classroom), - onBack = onBack, - modifier = Modifier - .fillMaxSize() - .appLiquidGlassSceneBackground(96.n1 withNight 10.n1) - , - bottomPadding = 112.dp, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { + val pageContent: LazyListScope.() -> Unit = { presetCandidates.firstOrNull()?.let { candidate -> item(key = "preset-${candidate.opportunityId}-${candidate.presetId}") { LaunchedEffect(candidate.opportunityId, candidate.presetId) { @@ -333,6 +328,33 @@ fun FreeClassroom( } } } + + if (isRadiantUi) { + SecondaryPageScaffold( + title = stringResource(id = R.string.free_classroom), + contentEdgeToEdge = true + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding(), + contentPadding = PaddingValues(top = 72.dp, bottom = 112.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + content = pageContent + ) + } + } else { + AppLazyPageLayout( + title = stringResource(id = R.string.free_classroom), + onBack = onBack, + modifier = Modifier + .fillMaxSize() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), + bottomPadding = 112.dp, + verticalArrangement = Arrangement.spacedBy(16.dp), + content = pageContent + ) + } } @Composable 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 e7f9ae91..3ddf4730 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 @@ -20,6 +20,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.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -45,7 +46,11 @@ 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.GlassCard import com.ahu.ahutong.ui.components.LocalAppUiTheme +import com.ahu.ahutong.ui.components.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.SecondarySearchState +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.GradeViewModel import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel @@ -80,6 +85,7 @@ fun Grade( var searchExpanded by rememberSaveable { mutableStateOf(false) } var searchQuery by rememberSaveable { mutableStateOf("") } + var termMenuExpanded by rememberSaveable { mutableStateOf(false) } BackHandler(enabled = searchExpanded) { searchExpanded = false @@ -140,36 +146,24 @@ fun Grade( } .orEmpty() - AppScrollablePageLayout( - title = stringResource(id = R.string.grade), - onBack = onBack, - scrollState = scrollState, - modifier = Modifier - .fillMaxSize() - .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() - } - ) - 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 = "" - } - ) + val radiant = isRadiantUi + val allTerms = gradeViewModel.grade?.termGradeList + ?.sortedWith( + compareByDescending { + it.schoolYear.substringBefore("-").toIntOrNull() ?: 0 + }.thenByDescending { + it.term.toIntOrNull() ?: 0 + } + ) + .orEmpty() + val selectedTermText = gradeViewModel.schoolYear?.let { schoolYear -> + gradeViewModel.schoolTerm?.let { schoolTerm -> + "$schoolYear 第${schoolTerm}学期" } - ) { - if (searchExpanded) { + } ?: "选择学期" + + val pageContent: @Composable ColumnScope.() -> Unit = { + if (searchExpanded && !radiant) { AppSearchField( value = searchQuery, onValueChange = { searchQuery = it }, @@ -203,17 +197,7 @@ fun Grade( } // 改成学期下拉选择(替代原来的学年+学期双筛选) - if (!searchExpanded) { - val allTerms = gradeViewModel.grade?.termGradeList - ?.sortedWith( - compareByDescending { - // 提取学年起始值,例如 "2023-2024" -> 2023 - it.schoolYear.substringBefore("-").toIntOrNull() ?: 0 - }.thenByDescending { - it.term.toIntOrNull() ?: 0 - } - ) - .orEmpty() + if (!searchExpanded && !radiant) { AppSelectField( label = "选择学期", selected = gradeViewModel.schoolYear?.let { schoolYear -> @@ -238,48 +222,72 @@ fun Grade( ) } + if (radiant && !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 + ) + } + } + if (!searchExpanded) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - // Per-profile empty state + val summary: @Composable ColumnScope.() -> Unit = { val rankMsg = gradeViewModel.rankEmptyMessage if (gpaRankInfo == null && !rankMsg.isNullOrBlank()) { Text( text = rankMsg, - modifier = Modifier.padding(horizontal = 24.dp), + modifier = if (radiant) Modifier else Modifier.padding(horizontal = 24.dp), style = MaterialTheme.typography.titleMedium, color = 50.n1 withNight 70.n1 ) } - - val infoList = listOf( + listOf( "本学期平均绩点" to gradeViewModel.termGradePointAverage, "全程平均绩点" to gradeViewModel.totalGradePointAverage, "全程专业排名" to ((gpaRankInfo?.majorRank ?: "暂无").toString() + "/" + (gpaRankInfo?.majorHeadCount ?: "暂无")), "该学期专业排名" to ((currentRank?.majorRank ?: "暂无").toString() + "/" + (gpaRankInfo?.majorHeadCount ?: "暂无")), "最后更新时间" to (gpaRankInfo?.updatedDateTimeStr ?: "暂无") - ) - - infoList.forEach { (title, value) -> + ).forEach { (title, value) -> Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp), + .then(if (radiant) Modifier else Modifier.padding(horizontal = 24.dp)), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text( - text = title, - color = 0.n1 withNight 100.n1, - style = MaterialTheme.typography.titleMedium - ) - Text( - text = value, - color = 0.n1 withNight 100.n1, - style = MaterialTheme.typography.titleMedium - ) + Text(title, color = 0.n1 withNight 100.n1, style = MaterialTheme.typography.titleMedium) + Text(value, color = 0.n1 withNight 100.n1, style = MaterialTheme.typography.titleMedium) } } } + if (radiant) { + GlassCard( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + containerColor = 100.n1 withNight 20.n1 + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + content = summary + ) + } + } else { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + content = summary + ) + } } if (searchExpanded && trimmedQuery.isNotBlank()) { @@ -305,7 +313,7 @@ fun Grade( } else if (!searchExpanded && gradeData != null && gradeData.gradeList.isNotEmpty()) { Column( modifier = Modifier.padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) + verticalArrangement = Arrangement.spacedBy(if (radiant) 8.dp else 2.dp) ) { gradeData.gradeList.forEach { GradeCard( @@ -330,6 +338,142 @@ fun Grade( ) } } + + val refreshGrades = { + behaviorReporter.organic(AppActionId.MANUAL_REFRESH_GRADE) + gradeViewModel.refreshGrade() + } + if (radiant) { + SecondaryPageScaffold( + title = stringResource(id = R.string.grade), + subtitle = selectedTermText, + search = SecondarySearchState( + query = searchQuery, + visible = searchExpanded, + placeholder = "搜索课程", + onQueryChange = { searchQuery = it }, + onClose = { + searchExpanded = false + searchQuery = "" + }, + onSubmit = {} + ), + contentEdgeToEdge = true, + trailingContent = { + GradeTermMenuButton( + allTerms = allTerms, + selectedTermText = selectedTermText, + expanded = termMenuExpanded, + onExpandedChange = { termMenuExpanded = it }, + onSelect = { year, term -> + gradeViewModel.selectTerm(year, term) + termMenuExpanded = false + } + ) + GradeRadiantTitleButton( + icon = R.drawable.ic_refresh, + contentDescription = "刷新成绩", + onClick = refreshGrades + ) + GradeRadiantTitleButton( + icon = R.drawable.ic_find, + contentDescription = "搜索成绩", + onClick = { searchExpanded = true } + ) + } + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .systemBarsPadding() + .padding(top = 76.dp, bottom = 48.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + content = pageContent + ) + } + } else { + AppScrollablePageLayout( + title = stringResource(id = R.string.grade), + onBack = onBack, + scrollState = scrollState, + modifier = Modifier + .fillMaxSize() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), + bottomPadding = 48.dp, + actions = { + AppHeaderIconButton( + imageVector = Icons.Default.Refresh, + miuixImageVector = MiuixIcons.Useful.Refresh, + contentDescription = "刷新成绩", + onClick = refreshGrades + ) + 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 = "" + } + ) + }, + content = pageContent + ) + } +} + +@Composable +private fun GradeTermMenuButton( + allTerms: List, + selectedTermText: String, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onSelect: (String, String) -> Unit +) { + Box { + GradeRadiantTitleButton( + icon = R.drawable.ic_filter, + contentDescription = "选择学期:$selectedTermText", + onClick = { onExpandedChange(!expanded) } + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { onExpandedChange(false) }, + modifier = Modifier.background(99.n1 withNight 10.n1) + ) { + allTerms.forEach { term -> + DropdownMenuItem( + text = { Text("${term.schoolYear} 第${term.term}学期") }, + onClick = { onSelect(term.schoolYear, term.term) } + ) + } + } + } +} + +@Composable +private fun GradeRadiantTitleButton( + icon: Int, + contentDescription: String, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center + ) { + IconButton(onClick = onClick) { + Icon( + painter = painterResource(icon), + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp) + ) + } + } } @Composable @@ -345,7 +489,7 @@ private fun GradeCard( AppCard( modifier = Modifier .fillMaxWidth(), - shape = SmoothRoundedCornerShape(20.dp), + shape = SmoothRoundedCornerShape(if (isRadiantUi) 16.dp else 20.dp), contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp) ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { 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 99d29672..08685e78 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 @@ -6,6 +6,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -13,6 +14,7 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Refresh @@ -24,7 +26,9 @@ 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.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -34,6 +38,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.hilt.navigation.compose.hiltViewModel import coil.compose.AsyncImage +import com.ahu.ahutong.R import com.ahu.ahutong.data.dao.AHUCache import com.ahu.ahutong.data.mock.MockScenarioController import com.ahu.ahutong.data.crawler.model.adwnh.LostFoundItem @@ -50,6 +55,8 @@ 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.components.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.LostFoundViewModel import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel @@ -261,41 +268,7 @@ fun LostFound( } } - Box( - modifier = Modifier - .fillMaxSize() - .appLiquidGlassSceneBackground(96.n1 withNight 10.n1) - ) { - - AppLazyPageLayout( - title = "失物招领", - onBack = onBack, - state = listState, - modifier = Modifier.fillMaxSize(), - 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 = "" - } - ) - } - ) { + val pageContent: LazyListScope.() -> Unit = { item { Column( @@ -563,6 +536,75 @@ fun LostFound( } } } + } + + Box( + modifier = Modifier + .fillMaxSize() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1) + ) { + if (isRadiantUi) { + SecondaryPageScaffold( + title = "失物招领", + contentEdgeToEdge = true, + trailingContent = { + LostFoundRadiantTitleButton( + icon = R.drawable.ic_refresh, + contentDescription = "刷新失物招领", + onClick = lostFoundViewModel::refreshList + ) + LostFoundRadiantTitleButton( + icon = if (searchExpanded) null else R.drawable.ic_find, + imageVector = if (searchExpanded) Icons.Default.Close else null, + contentDescription = if (searchExpanded) "关闭搜索" else "搜索", + onClick = { + searchExpanded = !searchExpanded + if (!searchExpanded) searchQuery = "" + } + ) + } + ) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .systemBarsPadding(), + contentPadding = PaddingValues(top = 72.dp, bottom = 96.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + content = pageContent + ) + } + } else { + AppLazyPageLayout( + title = "失物招领", + onBack = onBack, + state = listState, + modifier = Modifier.fillMaxSize(), + 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 = "" + } + ) + }, + content = pageContent + ) } AppFloatingActionButton( onClick = { @@ -1018,3 +1060,34 @@ fun LostFound( } } } + +@Composable +private fun LostFoundRadiantTitleButton( + icon: Int? = null, + imageVector: ImageVector? = null, + contentDescription: String, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center + ) { + IconButton(onClick = onClick) { + when { + icon != null -> Icon( + painter = painterResource(icon), + contentDescription = contentDescription, + modifier = Modifier.size(18.dp) + ) + imageVector != null -> Icon( + imageVector = imageVector, + contentDescription = contentDescription, + modifier = Modifier.size(18.dp) + ) + } + } + } +} 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 1f51b227..6dec9870 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 @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -43,8 +44,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -60,6 +63,9 @@ 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.components.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.SecondarySearchState +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.TelDirectoryViewModel import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel @@ -99,27 +105,14 @@ fun PhoneBook(onBack: (() -> Unit)? = null) { } } - AppLazyPageLayout( - title = stringResource(id = R.string.phone_book), - onBack = onBack, - modifier = Modifier - .fillMaxSize() - .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 = "" - } - ) - } - ) { + val radiant = isRadiantUi + val toggleSearch = { + isSearchActive = !isSearchActive + if (!isSearchActive) searchQuery = "" + } + val pageContent: LazyListScope.() -> Unit = { if (isSearchActive) { - item(key = "search") { + if (!radiant) item(key = "search") { AppSearchField( value = searchQuery, onValueChange = { searchQuery = it }, @@ -174,12 +167,88 @@ fun PhoneBook(onBack: (() -> Unit)? = null) { } } } + + if (radiant) { + SecondaryPageScaffold( + title = stringResource(id = R.string.phone_book), + search = SecondarySearchState( + query = searchQuery, + visible = isSearchActive, + placeholder = "搜索电话或部门", + onQueryChange = { searchQuery = it }, + onClose = { + isSearchActive = false + searchQuery = "" + }, + onSubmit = {} + ), + trailingContent = { + PhoneBookTitleButton( + icon = R.drawable.ic_find, + contentDescription = "搜索", + onClick = { isSearchActive = true } + ) + }, + contentEdgeToEdge = true + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding(), + contentPadding = PaddingValues(top = 72.dp, bottom = 48.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + content = pageContent + ) + } + } else { + AppLazyPageLayout( + title = stringResource(id = R.string.phone_book), + onBack = onBack, + modifier = Modifier + .fillMaxSize() + .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 = toggleSearch + ) + }, + content = pageContent + ) + } DialDialog( onDismiss = { dialData = null }, tel = dialData ) } +@Composable +private fun PhoneBookTitleButton( + icon: Int, + contentDescription: String, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center + ) { + IconButton(onClick = onClick) { + Icon( + painter = painterResource(icon), + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(18.dp) + ) + } + } +} + private fun openTelOrChooseCampus( context: android.content.Context, tel: Tel, 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 07b8d4ec..58b4cf67 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 @@ -12,12 +12,14 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope 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.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -26,6 +28,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.outlined.OpenInNew @@ -51,6 +54,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -70,6 +74,8 @@ 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.components.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.state.RepositoryMarkdownUiState import com.ahu.ahutong.ui.state.RepositoryViewModel import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel @@ -138,40 +144,7 @@ fun Repository( } } - AppPageLayout( - title = "学习资料", - onBack = { navController.popBackStack() }, - modifier = Modifier - .fillMaxSize() - .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) - } - ) - AppHeaderIconButton( - imageVector = Icons.Outlined.Download, - miuixImageVector = MiuixIcons.Useful.Save, - contentDescription = "已下载", - tint = MaterialTheme.colorScheme.primary, - onClick = { navController.navigate("repository_downloads") } - ) - AppHeaderIconButton( - imageVector = Icons.Outlined.Tune, - miuixImageVector = MiuixIcons.Useful.Settings, - contentDescription = "学习资料设置", - onClick = { navController.navigate("repository_settings") } - ) - } - ) { - Column(modifier = Modifier.fillMaxSize()) { - + val pageContent: @Composable ColumnScope.() -> Unit = { RepositoryBreadcrumb( currentPath = path, onPathClick = { targetPath -> @@ -287,6 +260,89 @@ fun Repository( } } } + + val refresh = { + behaviorReporter.organic( + if (state.error == null) AppActionId.MANUAL_REFRESH_REPOSITORY + else AppActionId.RETRY_REPOSITORY + ) + viewModel.refreshDirectory(path) + } + if (isRadiantUi) { + SecondaryPageScaffold( + title = "学习资料", + contentEdgeToEdge = true, + trailingContent = { + RepositoryRadiantTitleButton( + loading = state.isLoading || state.isRefreshing || sharedState.isCacheWarming, + onClick = refresh + ) { + Icon( + painter = painterResource(R.drawable.ic_refresh), + contentDescription = "刷新", + modifier = Modifier.size(18.dp) + ) + } + RepositoryRadiantTitleButton( + onClick = { navController.navigate("repository_downloads") } + ) { + Icon( + painter = painterResource(R.drawable.ic_download), + contentDescription = "已下载", + modifier = Modifier.size(18.dp) + ) + } + RepositoryRadiantTitleButton( + onClick = { navController.navigate("repository_settings") } + ) { + Icon( + painter = painterResource(R.drawable.ic_config), + contentDescription = "学习资料设置", + modifier = Modifier.size(18.dp) + ) + } + } + ) { + Column( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + ) { + Spacer(modifier = Modifier.height(72.dp)) + pageContent() + } + } + } else { + AppPageLayout( + title = "学习资料", + onBack = { navController.popBackStack() }, + modifier = Modifier + .fillMaxSize() + .appLiquidGlassSceneBackground(96.n1 withNight 10.n1), + actions = { + RepositoryRefreshButton( + loading = state.isLoading || state.isRefreshing || sharedState.isCacheWarming, + onRefresh = refresh + ) + AppHeaderIconButton( + imageVector = Icons.Outlined.Download, + miuixImageVector = MiuixIcons.Useful.Save, + contentDescription = "已下载", + tint = MaterialTheme.colorScheme.primary, + onClick = { navController.navigate("repository_downloads") } + ) + AppHeaderIconButton( + imageVector = Icons.Outlined.Tune, + miuixImageVector = MiuixIcons.Useful.Settings, + contentDescription = "学习资料设置", + onClick = { navController.navigate("repository_settings") } + ) + } + ) { + Column(modifier = Modifier.fillMaxSize()) { + pageContent() + } + } } RepositoryMarkdownReader( @@ -295,6 +351,29 @@ fun Repository( ) } +@Composable +private fun RepositoryRadiantTitleButton( + loading: Boolean = false, + onClick: () -> Unit, + content: @Composable () -> Unit +) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center + ) { + IconButton(onClick = onClick, enabled = !loading) { + if (loading) { + AppCircularProgressIndicator(size = 18.dp, strokeWidth = 2.dp) + } else { + content() + } + } + } +} + private fun repositoryResultBucket(count: Int): ResultCountBucket = when (count) { 0 -> ResultCountBucket.ZERO in 1..5 -> ResultCountBucket.ONE_TO_FIVE 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 1bb73665..92378a77 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 @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState @@ -58,6 +59,9 @@ 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.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.drawText import androidx.compose.ui.text.font.FontWeight @@ -68,10 +72,12 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import com.ahu.ahutong.R 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.components.isRadiantUi 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 @@ -203,14 +209,34 @@ fun Schedule( pagerState.animateScrollToPage((targetWeek - 1).coerceAtLeast(0)) } + val radiant = isRadiantUi val baseColor = 50.a1.toSrgb().toHct() - val courseColors = remember(schedule) { + val macaronPalette = remember { + listOf( + Color(0xFF82ADF7), Color(0xFF7AE3D2), Color(0xFF77B6EF), + Color(0xFFE19BB0), Color(0xFFE38874), Color(0xFF679ACD), + Color(0xFFE87897), Color(0xFFEBB877), Color(0xFFC8A2C8), + Color(0xFFA8E4A0), Color(0xFFFF8A80) + ) + } + val courseColors = remember(schedule, radiant) { 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() + if (radiant) { + courseNames.mapIndexed { index, name -> + val paletteIndex = if (index < macaronPalette.size) { + index + } else { + (name?.hashCode() ?: 0).mod(macaronPalette.size) + } + name to macaronPalette[paletteIndex] + }.toMap() + } else { + 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 -> @@ -252,13 +278,21 @@ fun Schedule( verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row( - modifier = Modifier.padding(end = 8.dp), + modifier = if (radiant) { + Modifier.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp) + } else { + Modifier.padding(end = 8.dp) + }, ) { // week selector LazyRow( modifier = Modifier.weight(1f), state = state, - contentPadding = PaddingValues(horizontal = 16.dp), + contentPadding = if (radiant) { + PaddingValues(0.dp) + } else { + PaddingValues(horizontal = 16.dp) + }, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { items(20) { @@ -299,7 +333,10 @@ fun Schedule( pagerState.animateScrollToPage(week - 1) } } - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding( + horizontal = 16.dp, + vertical = if (radiant) 8.dp else 12.dp + ), color = animateColorAsState( targetValue = if (isSelected) { 100.n1 withNight 0.n1 @@ -315,17 +352,21 @@ fun Schedule( } // actions Row( - modifier = Modifier - .appLiquidGlassSurface( + modifier = (if (radiant) { + Modifier + .clip(ContinuousCapsule) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)) + } else { + Modifier.appLiquidGlassSurface( shape = ContinuousCapsule, fallbackColor = 100.n1 withNight 30.n1, level = LiquidGlassSurfaceLevel.Floating ) - .padding(horizontal = 2.dp, vertical = 2.dp) + }).padding(horizontal = 2.dp, vertical = 2.dp) ) { IconButton( - modifier = Modifier.size(48.dp), + modifier = Modifier.size(if (radiant) 38.dp else 48.dp), onClick = { if (isPreviewNextSemester) { behaviorRuntime.recordCommittedMutationAsync( @@ -344,24 +385,40 @@ fun Schedule( } } ) { - Icon( - imageVector = Icons.Default.MyLocation, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) + if (radiant) { + Icon( + painter = painterResource(R.drawable.ic_aiming), + contentDescription = "回到本周", + modifier = Modifier.size(17.dp) + ) + } else { + Icon( + imageVector = Icons.Default.MyLocation, + contentDescription = "回到本周", + modifier = Modifier.size(20.dp) + ) + } } IconButton( - modifier = Modifier.size(48.dp), + modifier = Modifier.size(if (radiant) 38.dp else 48.dp), onClick = { isSettingsVisible = true } ) { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) + if (radiant) { + Icon( + painter = painterResource(R.drawable.ic_config), + contentDescription = "课表设置", + modifier = Modifier.size(17.dp) + ) + } else { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "课表设置", + modifier = Modifier.size(20.dp) + ) + } } IconButton( - modifier = Modifier.size(48.dp), + modifier = Modifier.size(if (radiant) 38.dp else 48.dp), onClick = { if (isPreviewNextSemester) { scheduleViewModel.refreshNextSchedule(true) @@ -371,17 +428,26 @@ fun Schedule( } } ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) + if (radiant) { + Icon( + painter = painterResource(R.drawable.ic_refresh), + contentDescription = "刷新课表", + modifier = Modifier.size(17.dp) + ) + } else { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "刷新课表", + modifier = Modifier.size(20.dp) + ) + } } } } // schedule val cellWidth = ( LocalConfiguration.current.screenWidthDp.dp - + (if (radiant) 12.dp else 0.dp) - CourseCardSpec.mainColumnWidth - CourseCardSpec.cellSpacing * 9 ) / 7 @@ -395,6 +461,9 @@ fun Schedule( modifier = with(CourseCardSpec) { Modifier .fillMaxWidth() + .then( + if (radiant) Modifier.padding(horizontal = 6.dp) else Modifier + ) .height(mainRowHeight + (cellHeight + cellSpacing) * 13 + 24.dp) .appLiquidGlassSurface( shape = SmoothRoundedCornerShape(32.dp), @@ -505,6 +574,7 @@ private fun BoxScope.ScheduleGridLabels( isInSemester: Boolean, isPreviewNextSemester: Boolean ) { + val radiant = isRadiantUi val textMeasurer = rememberTextMeasurer() val contentColor = LocalContentColor.current val secondaryColor = 50.n1 withNight 80.n1 @@ -512,8 +582,12 @@ private fun BoxScope.ScheduleGridLabels( val selectedContent = 0.n1 val dayStyle = MaterialTheme.typography.labelLarge val secondaryStyle = MaterialTheme.typography.labelSmall - val dayNames = remember { - listOf("周一", "周二", "周三", "周四", "周五", "周六", "周日") + val dayNames = remember(radiant) { + if (radiant) { + listOf("一", "二", "三", "四", "五", "六", "日") + } else { + listOf("周一", "周二", "周三", "周四", "周五", "周六", "周日") + } } val timeLabels = remember { ScheduleViewModel.timetable.map { (index, time) -> @@ -540,6 +614,20 @@ private fun BoxScope.ScheduleGridLabels( val spacingPx = CourseCardSpec.cellSpacing.toPx() val cornerRadius = CornerRadius(8.dp.toPx()) + if (radiant && weekDates.isNotEmpty()) { + val month = weekDates.first().substringBefore("-").trimStart('0') + drawCentered( + text = month, + style = dayStyle.copy(color = secondaryColor), + center = Offset(mainColumnWidthPx / 2f, mainRowHeightPx * 0.34f) + ) + drawCentered( + text = "月", + style = secondaryStyle.copy(color = secondaryColor), + center = Offset(mainColumnWidthPx / 2f, mainRowHeightPx * 0.70f) + ) + } + weekDates.forEachIndexed { index, date -> val left = mainColumnWidthPx + (cellWidthPx + spacingPx) * index + spacingPx val isCurrentWeekday = !isPreviewNextSemester && @@ -563,7 +651,7 @@ private fun BoxScope.ScheduleGridLabels( center = Offset(centerX, mainRowHeightPx * 0.34f) ) drawCentered( - text = date, + text = if (radiant) date.substringAfter("-").trimStart('0') else date, style = secondaryStyle.copy(color = dateColor), center = Offset(centerX, mainRowHeightPx * 0.70f) ) @@ -579,7 +667,8 @@ private fun BoxScope.ScheduleGridLabels( ) drawCentered( text = time, - style = secondaryStyle.copy(color = secondaryColor), + style = (if (radiant) TextStyle(fontSize = 9.sp) else secondaryStyle) + .copy(color = secondaryColor), center = Offset(centerX, top + cellHeightPx * 0.70f) ) } @@ -748,7 +837,8 @@ private fun OverviewCourseGroupCard( ) { OverviewCourseContent( course = item, - stackedCount = sortedCourses.size + stackedCount = sortedCourses.size, + slotHeightDp = fullHeight / sortedCourses.size ) } } @@ -768,8 +858,13 @@ private fun OverviewCourseGroupCard( @Composable private fun BoxScope.OverviewCourseContent( course: Course, - stackedCount: Int + stackedCount: Int, + slotHeightDp: Dp ) { + if (isRadiantUi) { + RadiantOverviewCourseContent(course, stackedCount, slotHeightDp) + return + } Text( text = course.name ?: "", modifier = Modifier.padding(bottom = 38.dp), @@ -804,6 +899,80 @@ private fun BoxScope.OverviewCourseContent( } } +@Composable +private fun BoxScope.RadiantOverviewCourseContent( + course: Course, + stackedCount: Int, + slotHeightDp: Dp +) { + val locationText = course.location.shortScheduleLocation() + val weekText = course.weekRangeText() + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + val maxLines = remember( + course.name, + locationText, + weekText, + stackedCount, + slotHeightDp, + density + ) { + val pillHeight = with(density) { + textMeasurer.measure( + text = AnnotatedString(locationText), + style = TextStyle(fontSize = 9.sp, fontWeight = FontWeight.Bold) + ).size.height.toDp() + 8.dp + } + val weekHeight = with(density) { + textMeasurer.measure( + text = AnnotatedString(weekText), + style = TextStyle(fontSize = 9.sp, fontWeight = FontWeight.Bold) + ).size.height.toDp() + } + val nameLineHeight = with(density) { + textMeasurer.measure( + text = AnnotatedString("测试"), + style = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Bold) + ).size.height.toDp() + } + val usableHeight = slotHeightDp - 8.dp - weekHeight - pillHeight - 4.dp + (usableHeight / nameLineHeight).toInt().coerceIn(1, 8) + } + + Text( + text = course.name ?: "", + modifier = Modifier + .fillMaxSize() + .wrapContentHeight(Alignment.Top) + .padding(bottom = 42.dp), + color = 100.n1, + fontWeight = FontWeight.Bold, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + style = TextStyle(fontSize = 12.sp) + ) + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + verticalArrangement = Arrangement.spacedBy(2.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = weekText, + color = 100.n1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + style = TextStyle(fontSize = 9.sp, fontWeight = FontWeight.Bold) + ) + OverviewLocationPill( + text = locationText, + maxLines = if (stackedCount <= 1) 2 else 1 + ) + } +} + @Composable private fun OverviewLocationPill( text: String, @@ -820,7 +989,7 @@ private fun OverviewLocationPill( overflow = TextOverflow.Ellipsis, maxLines = maxLines, style = TextStyle( - fontSize = 11.sp, + fontSize = if (isRadiantUi) 9.sp else 11.sp, color = 10.n1 withNight 90.n1, fontWeight = FontWeight.Bold ) 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 c707e7d5..368f6962 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 @@ -18,17 +18,21 @@ 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.platform.LocalDensity 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.rememberTextMeasurer 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.Constraints import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.ahu.ahutong.data.model.Course +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.kyant.monet.LocalTonalPalettes import com.kyant.monet.PaletteStyle @@ -63,6 +67,38 @@ fun CourseCard( CompositionLocalProvider( LocalTonalPalettes provides tonalPalettes ) { + val nameMaxLines = if (isRadiantUi) { + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + val capsuleText = if (isCurrentWeek) { + course.location.shortScheduleLocation() + } else { + "非本周" + } + remember(course.name, course.length, capsuleText, cellWidth, cellHeight, density) { + val capsuleLayout = textMeasurer.measure( + text = capsuleText, + style = TextStyle(fontSize = 9.sp, fontWeight = FontWeight.Bold), + overflow = TextOverflow.Ellipsis, + maxLines = 2, + constraints = Constraints( + maxWidth = with(density) { (cellWidth - 12.dp).roundToPx() } + ) + ) + val nameLineHeight = textMeasurer.measure( + text = "口", + style = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Bold) + ).size.height.coerceAtLeast(1) + val cardHeight = with(density) { + (cellHeight * course.length + + CourseCardSpec.cellSpacing * (course.length - 1)).roundToPx() + } + val reservedHeight = capsuleLayout.size.height + with(density) { 20.dp.roundToPx() } + ((cardHeight - reservedHeight) / nameLineHeight).coerceIn(1, 8) + } + } else { + 3 + } Box( modifier = with(CourseCardSpec) { Modifier @@ -103,8 +139,12 @@ fun CourseCard( color = 100.n1, fontWeight = FontWeight.Bold, overflow = TextOverflow.Ellipsis, - maxLines = 3, - style = MaterialTheme.typography.labelMedium + maxLines = nameMaxLines, + style = if (isRadiantUi) { + TextStyle(fontSize = 12.sp) + } else { + MaterialTheme.typography.labelMedium + } ) @@ -132,7 +172,9 @@ fun CourseCard( overflow = TextOverflow.Ellipsis, maxLines = 2, style = TextStyle( - fontSize = 11.sp, color = 10.n1 withNight 90.n1, fontWeight = FontWeight.Bold + fontSize = if (isRadiantUi) 9.sp else 11.sp, + color = 10.n1 withNight 90.n1, + fontWeight = FontWeight.Bold ) ) } From 389b183052b925e9929c1ca717e7db0558988a7d Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Sat, 5 Sep 2026 00:13:37 +0800 Subject: [PATCH 18/29] fix(radiant): add missing page imports --- app/src/main/java/com/ahu/ahutong/ui/screen/main/Grade.kt | 1 + app/src/main/java/com/ahu/ahutong/ui/screen/main/PhoneBook.kt | 1 + app/src/main/java/com/ahu/ahutong/ui/screen/main/Repository.kt | 1 + 3 files changed, 3 insertions(+) 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 3ddf4730..4c3f5850 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 @@ -7,6 +7,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close 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 6dec9870..b1fffcc4 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 @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn 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 58b4cf67..5bacbdb7 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 @@ -66,6 +66,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.LocalActivity import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController +import com.ahu.ahutong.R import com.ahu.ahutong.data.repository.GitHubContentItem import com.ahu.ahutong.data.repository.RepositoryDirectorySummary import com.ahu.ahutong.data.repository.RepositoryManager From f04a8423327dd1bd3134755e94df1a1d2ec00e62 Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Sat, 5 Sep 2026 00:48:19 +0800 Subject: [PATCH 19/29] fix(weather): restore radiant header and themed settings --- .../com/ahu/ahutong/ui/screen/main/Weather.kt | 147 ++++++++++++------ 1 file changed, 98 insertions(+), 49 deletions(-) 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 b2ade47e..d0705c78 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 @@ -31,11 +31,14 @@ 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.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.vectorResource 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.R import com.ahu.ahutong.data.weather.WeatherResponse import com.ahu.ahutong.ui.components.appLiquidGlassSceneBackground import com.ahu.ahutong.ui.components.appLiquidGlassSurface @@ -47,6 +50,10 @@ 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.components.SecondaryPageScaffold +import com.ahu.ahutong.ui.components.SecondarySearchState +import com.ahu.ahutong.ui.components.TrailingAction +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.state.WeatherHomeMode import com.ahu.ahutong.ui.state.WeatherViewModel import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel @@ -101,52 +108,7 @@ fun Weather( } } - AppScrollablePageLayout( - title = weatherViewModel.locationName.ifBlank { "天气" }, - onBack = onBack, - modifier = Modifier - .fillMaxSize() - .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 = "" - } - ) - 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() - } - ) - } - ) { - Column(modifier = Modifier.padding(horizontal = AppComponentTokens.HeaderHorizontalPadding)) { - if (showSearch) { - AppSearchField( - value = searchCity, - onValueChange = { searchCity = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = "输入城市名,如 合肥", - onSearch = { submitCitySearch() } - ) - Spacer(Modifier.height(16.dp)) - } - + val weatherContent: @Composable () -> Unit = { if (weatherViewModel.isLoading) { Box( modifier = Modifier.fillMaxWidth().padding(48.dp), @@ -216,6 +178,89 @@ fun Weather( } Spacer(Modifier.height(24.dp)) + } + } + + if (isRadiantUi) { + SecondaryPageScaffold( + title = weatherViewModel.locationName.ifBlank { "天气" }, + actions = listOf( + TrailingAction( + ImageVector.vectorResource(R.drawable.ic_find), + "搜索城市" + ) { showSearch = true }, + TrailingAction( + ImageVector.vectorResource(R.drawable.ic_config), + "设置" + ) { showSettings = true }, + TrailingAction( + ImageVector.vectorResource(R.drawable.ic_refresh), + "刷新" + ) { + weatherViewModel.refresh() + Toast.makeText(context, "已刷新", Toast.LENGTH_SHORT).show() + } + ), + search = SecondarySearchState( + query = searchCity, + visible = showSearch, + onQueryChange = { searchCity = it }, + onClose = { + showSearch = false + searchCity = "" + }, + onSubmit = { submitCitySearch() } + ) + ) { + weatherContent() + } + } else { + AppScrollablePageLayout( + title = weatherViewModel.locationName.ifBlank { "天气" }, + onBack = onBack, + modifier = Modifier + .fillMaxSize() + .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 = "" + } + ) + 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() + } + ) + } + ) { + Column(modifier = Modifier.padding(horizontal = AppComponentTokens.HeaderHorizontalPadding)) { + if (showSearch) { + AppSearchField( + value = searchCity, + onValueChange = { searchCity = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = "输入城市名,如 合肥", + onSearch = { submitCitySearch() } + ) + Spacer(Modifier.height(16.dp)) + } + weatherContent() } } } @@ -235,14 +280,14 @@ fun Weather( Text( "选择首页天气样式和详细卡片信息:", style = MaterialTheme.typography.bodyMedium, - color = 50.n1 withNight 80.n1 + color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(Modifier.height(8.dp)) Text( "首页样式", style = MaterialTheme.typography.labelLarge, - color = 0.n1 withNight 100.n1 + color = MaterialTheme.colorScheme.onSurface ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { WeatherModeChip( @@ -290,7 +335,11 @@ fun Weather( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text(item.label, modifier = Modifier.weight(1f), color = 0.n1 withNight 100.n1) + Text( + item.label, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurface + ) AppToggle( checked = item.value, onCheckedChange = item.onChange, From 270238fd48f925300e6559cf99cb84b044f5c854 Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Sat, 5 Sep 2026 00:49:44 +0800 Subject: [PATCH 20/29] fix(settings): restore Radiant card shadows and icons --- .../ui/components/SettingsComponents.kt | 59 ++++++++++++++++-- .../com/ahu/ahutong/ui/screen/Settings.kt | 62 ++++++++++++++++--- 2 files changed, 110 insertions(+), 11 deletions(-) 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 96b06dde..67d50853 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 @@ -54,7 +54,9 @@ 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.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -318,7 +320,9 @@ fun SettingsHeroCard( content: @Composable RowScope.() -> Unit ) { val onClickWithFeedback = rememberThemeHapticAction(onClick) - if (LocalAppUiTheme.current == AppUiTheme.MIUIX) { + val uiTheme = LocalAppUiTheme.current + val isRadiant = uiTheme == AppUiTheme.RADIANT + if (uiTheme == AppUiTheme.MIUIX) { MiuixCard( modifier = modifier.fillMaxWidth(), cornerRadius = 16.dp, @@ -338,6 +342,19 @@ fun SettingsHeroCard( Row( modifier = modifier .fillMaxWidth() + .then( + if (isRadiant) { + Modifier.shadow( + elevation = 14.dp, + shape = shape, + clip = false, + ambientColor = Color.Black.copy(alpha = 0.12f), + spotColor = Color.Black.copy(alpha = 0.12f) + ) + } else { + Modifier + } + ) .appLiquidGlassSurface( shape = shape, fallbackColor = MaterialTheme.colorScheme.primaryContainer, @@ -359,7 +376,9 @@ fun SettingsSection( backdrop: Backdrop? = null, content: @Composable ColumnScope.() -> Unit ) { - if (LocalAppUiTheme.current == AppUiTheme.MIUIX) { + val uiTheme = LocalAppUiTheme.current + val isRadiant = uiTheme == AppUiTheme.RADIANT + if (uiTheme == AppUiTheme.MIUIX) { Column(modifier = modifier.fillMaxWidth()) { MiuixSmallTitle(text = title) MiuixCard( @@ -392,6 +411,19 @@ fun SettingsSection( Column( modifier = Modifier .fillMaxWidth() + .then( + if (isRadiant) { + Modifier.shadow( + elevation = 14.dp, + shape = shape, + clip = false, + ambientColor = Color.Black.copy(alpha = 0.12f), + spotColor = Color.Black.copy(alpha = 0.12f) + ) + } else { + Modifier + } + ) .appLiquidGlassSurface( shape = shape, fallbackColor = settingsGroupColor(), @@ -410,6 +442,7 @@ fun SettingsActionRow( modifier: Modifier = Modifier, subtitle: String? = null, leadingIcon: ImageVector? = null, + leadingPainter: Painter? = null, value: String? = null, destructive: Boolean = false, showChevron: Boolean = true, @@ -467,7 +500,22 @@ fun SettingsActionRow( horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically ) { - leadingIcon?.let { + if (leadingPainter != null) { + Box( + modifier = Modifier + .size(40.dp) + .clip(SmoothRoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + painter = leadingPainter, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } else leadingIcon?.let { Box( modifier = Modifier .size(40.dp) @@ -504,7 +552,10 @@ fun SettingsActionRow( ) } } - SettingsDivider(visible = showDivider, leadingInset = if (leadingIcon == null) 20.dp else 74.dp) + SettingsDivider( + visible = showDivider, + leadingInset = if (leadingIcon == null && leadingPainter == null) 20.dp else 74.dp + ) } } 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 6e0beec4..50cd9102 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 @@ -72,6 +72,7 @@ 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.components.isRadiantUi import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.AboutViewModel import com.ahu.ahutong.ui.state.MainViewModel @@ -109,6 +110,7 @@ fun Settings( var lastAppCardTap by remember { mutableLongStateOf(0L) } val scheduleConfig by scheduleViewModel.scheduleConfig.observeAsState() val useMiuixIcons = LocalAppUiTheme.current == AppUiTheme.MIUIX + val isRadiant = isRadiantUi LaunchedEffect(tip) { tip?.let { @@ -192,9 +194,12 @@ fun Settings( title = "重新登录", leadingIcon = if (useMiuixIcons) { MiuixIcons.Useful.Personal + } else if (isRadiant) { + null } else { Icons.AutoMirrored.Outlined.Login }, + leadingPainter = if (isRadiant) painterResource(R.drawable.ic_logout) else null, showDivider = false, onClick = { navController.navigate("login") } ) @@ -208,12 +213,26 @@ fun Settings( ) { SettingsActionRow( title = stringResource(id = R.string.preferences), - leadingIcon = if (useMiuixIcons) MiuixIcons.Useful.Settings else Icons.Outlined.Tune, + leadingIcon = when { + useMiuixIcons -> MiuixIcons.Useful.Settings + isRadiant -> null + else -> Icons.Outlined.Tune + }, + leadingPainter = if (isRadiant) { + painterResource(R.drawable.ic_setting_config) + } else { + null + }, onClick = { navController.navigate("preferences") } ) SettingsActionRow( title = stringResource(id = R.string.check_update), - leadingIcon = if (useMiuixIcons) MiuixIcons.Useful.Update else Icons.Outlined.Update, + leadingIcon = when { + useMiuixIcons -> MiuixIcons.Useful.Update + isRadiant -> null + else -> Icons.Outlined.Update + }, + leadingPainter = if (isRadiant) painterResource(R.drawable.ic_update) else null, showDivider = false, onClick = { mainViewModel.checkApkUpdateManually(context) { message -> @@ -230,17 +249,36 @@ fun Settings( ) { SettingsActionRow( title = stringResource(id = R.string.license), - leadingIcon = if (useMiuixIcons) MiuixIcons.Useful.Info else Icons.AutoMirrored.Outlined.Article, + leadingIcon = when { + useMiuixIcons -> MiuixIcons.Useful.Info + isRadiant -> null + else -> Icons.AutoMirrored.Outlined.Article + }, + leadingPainter = if (isRadiant) { + painterResource(R.drawable.ic_announcement) + } else { + null + }, onClick = { navController.navigate("settings__license") } ) SettingsActionRow( title = stringResource(id = R.string.contributors), - leadingIcon = if (useMiuixIcons) MiuixIcons.Useful.Personal else Icons.Outlined.PeopleOutline, + leadingIcon = when { + useMiuixIcons -> MiuixIcons.Useful.Personal + isRadiant -> null + else -> Icons.Outlined.PeopleOutline + }, + leadingPainter = if (isRadiant) painterResource(R.drawable.ic_peoples) else null, onClick = { navController.navigate("settings__contributors") } ) SettingsActionRow( title = stringResource(id = R.string.mine_tv_feedback), - leadingIcon = if (useMiuixIcons) MiuixIcons.Useful.Edit else Icons.Outlined.Feedback, + leadingIcon = when { + useMiuixIcons -> MiuixIcons.Useful.Edit + isRadiant -> null + else -> Icons.Outlined.Feedback + }, + leadingPainter = if (isRadiant) painterResource(R.drawable.ic_topic) else null, onClick = { runCatching { context.startActivity( @@ -256,12 +294,22 @@ fun Settings( ) SettingsActionRow( title = stringResource(id = R.string.update_intro), - leadingIcon = if (useMiuixIcons) MiuixIcons.Useful.Info else Icons.AutoMirrored.Outlined.Article, + leadingIcon = when { + useMiuixIcons -> MiuixIcons.Useful.Info + isRadiant -> null + else -> Icons.AutoMirrored.Outlined.Article + }, + leadingPainter = if (isRadiant) painterResource(R.drawable.ic_log) else null, onClick = { isUpdateLogDialogShown = true } ) SettingsActionRow( title = stringResource(id = R.string.setting_clear), - leadingIcon = if (useMiuixIcons) MiuixIcons.Useful.Delete else Icons.Outlined.ClearAll, + leadingIcon = when { + useMiuixIcons -> MiuixIcons.Useful.Delete + isRadiant -> null + else -> Icons.Outlined.ClearAll + }, + leadingPainter = if (isRadiant) painterResource(R.drawable.ic_clear) else null, destructive = true, showDivider = false, onClick = { isClearDataDialogShown = true } From 1c8716df6058ca4aac57cbd7fc6ea23b8d8ad38a Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Sat, 5 Sep 2026 00:50:48 +0800 Subject: [PATCH 21/29] fix(schedule): use semantic settings colors --- .../main/java/com/ahu/ahutong/ui/screen/main/Schedule.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 92378a77..59f6b1ba 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 @@ -708,7 +708,7 @@ private fun ScheduleSettingsDialog( text = "课表设置", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge, - color = 0.n1 withNight 100.n1 + color = MaterialTheme.colorScheme.onSurface ) }, text = { @@ -731,7 +731,7 @@ private fun ScheduleSettingsDialog( TextButton(onClick = onDismiss) { Text( text = "完成", - color = 40.a1 withNight 80.a1 + color = MaterialTheme.colorScheme.primary ) } } @@ -763,11 +763,11 @@ private fun ScheduleSettingsDialog( text = title, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge, - color = 0.n1 withNight 100.n1 + color = MaterialTheme.colorScheme.onSurface ) Text( text = description, - color = 50.n1 withNight 80.n1, + color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall ) } From 7611df1a777203e8b0dbbd9dea3088da6dd57c1c Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Sat, 5 Sep 2026 00:56:54 +0800 Subject: [PATCH 22/29] fix(home): restore radiant navigation and motion --- .../ahutong/ui/components/LiquidBottomTabs.kt | 105 +++--------------- .../com/ahu/ahutong/ui/screen/BottomNavBar.kt | 29 ++++- .../java/com/ahu/ahutong/ui/screen/Main.kt | 44 ++++++-- .../com/ahu/ahutong/ui/screen/main/Home.kt | 4 + .../ui/screen/main/MoreWidgetsScreen.kt | 16 ++- .../com/ahu/ahutong/ui/screen/main/Tools.kt | 79 +++++++------ .../ahutong/ui/screen/main/home/CampusCard.kt | 79 +++++++------ .../ui/screen/main/home/TodayCourseList.kt | 7 +- 8 files changed, 185 insertions(+), 178 deletions(-) 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 ca44c3dc..927368d4 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 @@ -14,28 +14,22 @@ 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 import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.material3.MaterialTheme import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastCoerceIn @@ -46,9 +40,6 @@ import com.ahu.ahutong.ui.utils.DampedDragAnimation import com.ahu.ahutong.ui.utils.InteractiveHighlight import com.kyant.backdrop.Backdrop import com.kyant.backdrop.backdrops.emptyBackdrop -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberCombinedBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.lens @@ -59,8 +50,6 @@ import com.kyant.backdrop.shadow.Shadow import com.kyant.capsule.ContinuousCapsule import com.kyant.monet.n1 import com.kyant.monet.withNight -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.sign @@ -94,9 +83,6 @@ fun LiquidBottomTabs( else Color(0xFF121212).copy(0.4f) } - val tabsBackdrop = rememberLayerBackdrop() - val tabsSource: Backdrop = if (capturesBackdrop) tabsBackdrop else emptyBackdrop() - BoxWithConstraints( modifier, contentAlignment = Alignment.CenterStart @@ -118,13 +104,19 @@ fun LiquidBottomTabs( val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr val animationScope = rememberCoroutineScope() - var currentIndex by remember { - mutableIntStateOf(selectedTabIndex()) - } - val dampedDragAnimation = remember(animationScope, isLiquid) { + val requestedIndex = selectedTabIndex().coerceIn(0, tabsCount - 1) + val selectedIndexState = rememberUpdatedState(requestedIndex) + val onTabSelectedState = rememberUpdatedState(onTabSelected) + val dampedDragAnimation = remember( + animationScope, + isLiquid, + tabsCount, + tabWidth, + isLtr + ) { DampedDragAnimation( animationScope = animationScope, - initialValue = selectedTabIndex().toFloat(), + initialValue = requestedIndex.toFloat(), valueRange = 0f..(tabsCount - 1).toFloat(), visibilityThreshold = 0.001f, initialScale = 1f, @@ -133,8 +125,10 @@ fun LiquidBottomTabs( onDragStarted = {}, onDragStopped = { val targetIndex = targetValue.fastRoundToInt().fastCoerceIn(0, tabsCount - 1) - currentIndex = targetIndex animateToValue(targetIndex.toFloat()) + if (targetIndex != selectedIndexState.value) { + onTabSelectedState.value(targetIndex) + } animationScope.launch { offsetAnimation.animateTo( 0f, @@ -153,19 +147,8 @@ fun LiquidBottomTabs( } ) } - val requestedIndex = selectedTabIndex() - LaunchedEffect(requestedIndex) { - if (currentIndex != requestedIndex) { - currentIndex = requestedIndex - } - } - LaunchedEffect(dampedDragAnimation) { - snapshotFlow { currentIndex } - .drop(1) - .collectLatest { index -> - dampedDragAnimation.animateToValue(index.toFloat()) - onTabSelected(index) - } + LaunchedEffect(requestedIndex, dampedDragAnimation) { + dampedDragAnimation.animateToValue(requestedIndex.toFloat()) } val interactiveHighlight = remember(animationScope, isLiquid) { @@ -221,58 +204,6 @@ fun LiquidBottomTabs( content = content ) - CompositionLocalProvider( - LocalLiquidBottomTabScale provides { - if (isLiquid) lerp(1f, 1.2f, dampedDragAnimation.pressProgress) - else 1f - } - ) { - Row( - Modifier - .clearAndSetSemantics {} - .alpha(0f) - .then( - if (capturesBackdrop) Modifier.layerBackdrop(tabsBackdrop) else Modifier - ) - .graphicsLayer { - translationX = panelOffset - } - .drawBackdrop( - backdrop = backdrop, - shape = { ContinuousCapsule }, - effects = { - val progress = dampedDragAnimation.pressProgress - if (canBlur) { - vibrancy() - blur(tokens.floating.blurRadius.toPx()) - } - if (canRefract && progress > 0f) { - lens( - tokens.floating.refractionHeight.toPx() * progress, - tokens.floating.refractionAmount.toPx() * progress - ) - } - }, - highlight = { - if (isLiquid) { - val progress = dampedDragAnimation.pressProgress - Highlight.Default.copy(alpha = progress) - } else { - null - } - }, - onDrawSurface = { drawRect(containerColor) } - ) - .then(interactiveHighlight.modifier) - .height(56f.dp) - .fillMaxWidth() - .padding(horizontal = 4f.dp) - .graphicsLayer(colorFilter = ColorFilter.tint(accentColor)), - verticalAlignment = Alignment.CenterVertically, - content = content - ) - } - Box( Modifier .padding(horizontal = 4f.dp) @@ -299,7 +230,7 @@ fun LiquidBottomTabs( } ) .drawBackdrop( - backdrop = rememberCombinedBackdrop(backdrop, tabsSource), + backdrop = backdrop, shape = { ContinuousCapsule }, effects = { if (canRefract) { 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 36bff926..e9ac3028 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 @@ -119,7 +119,6 @@ private fun BoxScope.RadiantBottomNavBar( val destinations = listOf( RadiantDestination("home", "主页", R.drawable.ic_nav_home), RadiantDestination("schedule", "课表", R.drawable.ic_nav_schedule), - RadiantDestination("tools", "小工具", R.drawable.ic_nav_tools), RadiantDestination( "xuexiaotong", if (showingSchedule) "日程" else "课程", @@ -166,15 +165,25 @@ private fun BoxScope.RadiantBottomNavBar( ) { destinations.forEach { destination -> val selected = selectedRoute == destination.route + val contentColor = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } LiquidBottomTab( selected = selected, onClick = { select(destination.route) } ) { Icon( painter = painterResource(destination.iconId), - contentDescription = destination.label + contentDescription = destination.label, + tint = contentColor + ) + Text( + destination.label, + color = contentColor, + style = MaterialTheme.typography.labelMedium ) - Text(destination.label, style = MaterialTheme.typography.labelMedium) } } } @@ -265,6 +274,11 @@ private fun BoxScope.ClassicBottomNavBar( ) { classicDestinations.forEach { destination -> val selected = selectedRoute == destination.route + val contentColor = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } LiquidBottomTab( selected = selected, onClick = { onDestinationSelected(destination.route) } @@ -275,9 +289,14 @@ private fun BoxScope.ClassicBottomNavBar( } else { destination.unselectedIcon }, - contentDescription = destination.label + contentDescription = destination.label, + tint = contentColor + ) + Text( + destination.label, + color = contentColor, + style = MaterialTheme.typography.labelMedium ) - Text(destination.label, style = MaterialTheme.typography.labelMedium) } } } 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 49657aed..a776a193 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 @@ -53,6 +53,7 @@ import com.ahu.ahutong.ui.screen.main.FreeClassroom import com.ahu.ahutong.ui.screen.main.Grade import com.ahu.ahutong.ui.screen.main.Home import com.ahu.ahutong.ui.screen.main.LostFound +import com.ahu.ahutong.ui.screen.main.MoreWidgetsScreen import com.ahu.ahutong.ui.screen.main.NetworkRecharge import com.ahu.ahutong.ui.screen.main.PhoneBook import com.ahu.ahutong.ui.screen.main.Repository @@ -144,6 +145,15 @@ fun Main( ) } + fun requestHomeEdit() { + behaviorRuntime.recordActionIntentAsync( + AppActionId.EDIT_HOME, + ActionSource.ORGANIC + ) + shouldEnterHomeEdit = true + scope.launch { selectPrimaryDestination("home") } + } + LaunchedEffect(currentRoute) { if (currentRoute == "home") { delay(1_500L) @@ -222,14 +232,7 @@ fun Main( 2 -> Tools( navController = navController, homeEditEnabled = homeEditGrayState.enabled, - onEditHome = { - behaviorRuntime.recordActionIntentAsync( - AppActionId.EDIT_HOME, - ActionSource.ORGANIC - ) - shouldEnterHomeEdit = true - scope.launch { selectPrimaryDestination("home") } - } + onEditHome = ::requestHomeEdit ) 3 -> Settings( navController = navController, @@ -295,9 +298,26 @@ fun Main( ) } animatedComposable(appUiThemeState, "tools") { - PrimaryDestinationRedirect( + if (appUiTheme == AppUiTheme.RADIANT) { + LaunchedEffect(Unit) { + navController.navigate("widgets") { + popUpTo("tools") { inclusive = true } + launchSingleTop = true + } + } + Box(modifier = Modifier.fillMaxSize()) + } else { + PrimaryDestinationRedirect( + navController = navController, + onRedirect = { primaryPagerState.scrollToPage(2) } + ) + } + } + animatedComposable(appUiThemeState, "widgets") { + MoreWidgetsScreen( navController = navController, - onRedirect = { primaryPagerState.scrollToPage(2) } + homeEditEnabled = homeEditGrayState.enabled, + onEditHome = ::requestHomeEdit ) } animatedComposable(appUiThemeState, "school_calendar") { @@ -492,7 +512,9 @@ fun Main( navController.navigate("home") { launchSingleTop = true } } else { com.ahu.ahutong.personalization.action.AppActionCatalog.spec(action).route?.let { route -> - if (route in primaryDestinationRoutes) { + if (appUiTheme == AppUiTheme.RADIANT && route == "tools") { + navController.navigate("widgets") { launchSingleTop = true } + } else if (route in primaryDestinationRoutes) { if (currentRoute != "home") { navController.navigate("home") { popUpTo("home") { inclusive = false } 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 1f38e333..83a1a525 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 @@ -443,7 +443,9 @@ fun Home( enabled = !isEditingHome, trailingContent = trailingContent ) + if (radiant) Spacer(modifier = Modifier.height(12.dp)) if (todayCourses.isNotEmpty()) { + if (radiant) Spacer(modifier = Modifier.height(16.dp)) TodayCourseList( todayCourses = todayCourses, currentMinutes = currentMinutes, @@ -452,6 +454,7 @@ fun Home( ) } if (weatherHomeConfig.showOnHome && weatherHomeConfig.mode == WeatherHomeMode.Detailed) { + if (radiant) Spacer(modifier = Modifier.height(20.dp)) if (!isEditingHome) { HomeWeatherWidget( onClick = { navController.navigate("weather") }, @@ -460,6 +463,7 @@ fun Home( ) } } + if (radiant) Spacer(modifier = Modifier.height(8.dp)) HomeWidgetSlotLayout( balance = discoveryViewModel.balance, transitionBalance = discoveryViewModel.transitionBalance, diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/MoreWidgetsScreen.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/MoreWidgetsScreen.kt index 3f4af1c5..9f03ba44 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/MoreWidgetsScreen.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/MoreWidgetsScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import com.ahu.ahutong.R import com.ahu.ahutong.data.dao.AHUCache +import com.ahu.ahutong.data.dao.HomeWidgetLayoutFamily import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.screen.main.home.HomeWidgetRegistry @@ -38,7 +39,15 @@ fun MoreWidgetsScreen( homeEditEnabled: Boolean = false, onEditHome: () -> Unit = {} ) { - val homeWidgetIds = remember { AHUCache.getHomeWidgetSlots().filterNotNull().toSet() } + val radiant = isRadiantUi + val layoutFamily = if (radiant) { + HomeWidgetLayoutFamily.RADIANT + } else { + HomeWidgetLayoutFamily.CLASSIC + } + val homeWidgetIds = remember(layoutFamily) { + AHUCache.getHomeWidgetSlots(layoutFamily).filterNotNull().toSet() + } Column( modifier = Modifier .fillMaxSize() @@ -89,7 +98,7 @@ fun MoreWidgetsScreen( .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - HomeWidgetRegistry.availableWidgets(isRadiantUi) + HomeWidgetRegistry.availableWidgets(radiant) .filter { it.id !in homeWidgetIds } .forEach { widget -> ToolItem( @@ -100,5 +109,6 @@ fun MoreWidgetsScreen( ) } } + DesktopScheduleWidgetCard() } -} \ No newline at end of file +} 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 1f70eb9f..0fd79abf 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 @@ -94,8 +94,6 @@ fun Tools( homeEditEnabled: Boolean = false, onEditHome: () -> Unit = {} ) { - val context = LocalContext.current - val scope = rememberCoroutineScope() val radiant = isRadiantUi val layoutFamily = if (radiant) { HomeWidgetLayoutFamily.RADIANT @@ -173,44 +171,51 @@ fun Tools( ) } } - Column( + DesktopScheduleWidgetCard() + } +} + +@Composable +internal fun DesktopScheduleWidgetCard() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(32.dp), + fallbackColor = 100.n1 withNight 30.n1 + ), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "添加桌面课表微件", + modifier = Modifier.padding(24.dp), + style = MaterialTheme.typography.titleLarge + ) + AsyncImage( + model = ImageRequest.Builder(context) + .data(R.mipmap.schedule_widget_prev) + .crossfade(false) + .build(), + contentDescription = "桌面课表微件", + modifier = Modifier.align(Alignment.CenterHorizontally), + contentScale = ContentScale.Fit + ) + AppButton( + onClick = { + scope.launch { + GlanceAppWidgetManager(context).requestPinGlanceAppWidget( + ScheduleAppWidgetReceiver::class.java + ) + } + }, modifier = Modifier + .padding(16.dp) .fillMaxWidth() - .padding(horizontal = 16.dp) - .appLiquidGlassSurface( - shape = SmoothRoundedCornerShape(32.dp), - fallbackColor = 100.n1 withNight 30.n1 - ), - verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text( - text = "添加桌面课表微件", - modifier = Modifier.padding(24.dp), - style = MaterialTheme.typography.titleLarge - ) - AsyncImage( - model = ImageRequest.Builder(context) - .data(R.mipmap.schedule_widget_prev) - .crossfade(false) - .build(), - contentDescription = "桌面课表微件", - modifier = Modifier.align(Alignment.CenterHorizontally), - contentScale = ContentScale.Fit - ) - AppButton( - onClick = { - scope.launch { - GlanceAppWidgetManager(context).requestPinGlanceAppWidget( - ScheduleAppWidgetReceiver::class.java - ) - } - }, - modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - ) { - Text("添加", style = MaterialTheme.typography.titleMedium) - } + Text("添加", style = MaterialTheme.typography.titleMedium) } } } 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 c4d0290f..d58f86cd 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 @@ -1,7 +1,13 @@ package com.ahu.ahutong.ui.screen.main.home +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -122,30 +128,46 @@ fun CampusCard( } + val campusShape = SmoothRoundedCornerShape(24.dp) Box( - modifier = modifier + modifier = modifier.appLiquidGlassSurface( + shape = campusShape, + fallbackColor = 100.n1 withNight 20.n1, + backdropSamplingEnabled = isRadiantUi + ) ) { - if (isQrcode) { - QRcodeView( - balance = balance, - onBack = { - isQrcode = false - } - ) - } else { - CardView( - balance = balance, - transitionBalance = transitionBalance, - onClick = { - behaviorRuntime.recordActionIntentAsync(AppActionId.OPEN_PAYMENT_QR, ActionSource.ORGANIC) - isQrcode = true - }, - navController = navController, - enabled = enabled, - modifier = Modifier - .fillMaxWidth() - .height(if (isRadiantUi) 78.dp else 140.dp) - ) + AnimatedContent( + targetState = isQrcode, + transitionSpec = { + (fadeIn(tween(150)) togetherWith fadeOut(tween(150))) + .using(SizeTransform(clip = true)) + }, + contentAlignment = Alignment.TopStart, + label = "campus-card-qrcode" + ) { showQrcode -> + if (showQrcode) { + QRcodeView( + balance = balance, + onBack = { isQrcode = false } + ) + } else { + CardView( + balance = balance, + transitionBalance = transitionBalance, + onClick = { + behaviorRuntime.recordActionIntentAsync( + AppActionId.OPEN_PAYMENT_QR, + ActionSource.ORGANIC + ) + isQrcode = true + }, + navController = navController, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .height(if (isRadiantUi) 78.dp else 140.dp) + ) + } } } @@ -162,13 +184,8 @@ private fun CardView( enabled: Boolean, modifier: Modifier = Modifier ) { - val shape = SmoothRoundedCornerShape(24.dp) Row( - modifier = modifier - .appLiquidGlassSurface( - shape = shape, - fallbackColor = 100.n1 withNight 20.n1 - ), + modifier = modifier, verticalAlignment = Alignment.CenterVertically ) { @@ -352,13 +369,9 @@ private fun QRcodeView(balance: Double, onBack: () -> Unit) { } } - val panelShape = SmoothRoundedCornerShape(24.dp) Column( modifier = Modifier - .appLiquidGlassSurface( - shape = panelShape, - fallbackColor = 100.n1 withNight 20.n1 - ) + .fillMaxWidth() .padding( start = 20.dp, top = 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 476997ab..9019c69f 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 @@ -28,6 +28,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.ahu.ahutong.data.model.Course import com.ahu.ahutong.ui.components.appLiquidGlassSurface +import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.ScheduleViewModel import com.kyant.monet.a1 @@ -49,7 +50,8 @@ fun TodayCourseList( .padding(horizontal = 16.dp) .appLiquidGlassSurface( shape = panelShape, - fallbackColor = 100.n1 withNight 20.n1 + fallbackColor = 100.n1 withNight 20.n1, + backdropSamplingEnabled = isRadiantUi ) .then( if (enabled) { @@ -88,7 +90,8 @@ fun TodayCourseList( .padding(horizontal = 16.dp) .appLiquidGlassSurface( shape = panelShape, - fallbackColor = 100.n1 withNight 20.n1 + fallbackColor = 100.n1 withNight 20.n1, + backdropSamplingEnabled = isRadiantUi ) .then( if (enabled) { From d62d304e4a6b031da180b05faaf5056fd3ad877f Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Sat, 5 Sep 2026 01:02:07 +0800 Subject: [PATCH 23/29] fix(login): restore home redirect after sign-in --- app/src/main/java/com/ahu/ahutong/ui/screen/Main.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a776a193..89ffcf3f 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 @@ -267,10 +267,10 @@ fun Main( onLoggedIn = { scheduleViewModel.clear() scope.launch { - primaryPagerState.scrollToPage(0) navController.navigate("home") { - popUpTo("login") { inclusive = true } + popUpTo(navController.graph.id) { inclusive = true } } + primaryPagerState.scrollToPage(0) com.ahu.ahutong.data.dao.AHUCache.getCurrentUser()?.xh?.takeIf { it.isNotBlank() }?.let { behaviorRuntime.startProfile(it) } From 8e7ab0916a348dabfe34ea56d91868db205ad7a9 Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Sat, 5 Sep 2026 01:12:33 +0800 Subject: [PATCH 24/29] fix(free-classroom): restore radiant card surfaces --- .../ahutong/ui/screen/main/FreeClassroom.kt | 85 ++++++++++++------- 1 file changed, 56 insertions(+), 29 deletions(-) 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 8eba183f..703c6444 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 @@ -5,6 +5,7 @@ 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.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -38,6 +39,7 @@ 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.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -140,15 +142,23 @@ fun FreeClassroom( item { Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .appLiquidGlassSurface( - shape = SmoothRoundedCornerShape(24.dp), - fallbackColor = MaterialTheme.colorScheme.surfaceContainer, - level = LiquidGlassSurfaceLevel.Panel - ) - .padding(16.dp), + modifier = if (isRadiantUi) { + Modifier + .padding(horizontal = 16.dp) + .clip(SmoothRoundedCornerShape(32.dp)) + .background(100.n1 withNight 20.n1) + .padding(20.dp) + } else { + 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( @@ -442,16 +452,25 @@ private fun SupportingText(text: String) { @Composable 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) + modifier = if (isRadiantUi) { + Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .clip(SmoothRoundedCornerShape(20.dp)) + .background(95.n1 withNight 25.n1) + .padding(14.dp) + } else { + 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(if (isRadiantUi) 6.dp else 4.dp) ) { Text(room.nameZh, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Text( @@ -474,16 +493,24 @@ private fun MessageCard( onAction: (() -> Unit)? = null ) { 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(8.dp) + modifier = if (isRadiantUi) { + Modifier + .padding(horizontal = 16.dp) + .clip(SmoothRoundedCornerShape(32.dp)) + .background(100.n1 withNight 20.n1) + .padding(20.dp) + } else { + Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .appLiquidGlassSurface( + shape = SmoothRoundedCornerShape(20.dp), + fallbackColor = MaterialTheme.colorScheme.surfaceContainerLow, + level = LiquidGlassSurfaceLevel.Panel + ) + .padding(18.dp) + }, + verticalArrangement = Arrangement.spacedBy(if (isRadiantUi) 10.dp else 8.dp) ) { Text(title, style = MaterialTheme.typography.titleMedium) Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium) From 151fce865363d3a4dae862667964bea1c2523994 Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Sat, 5 Sep 2026 01:14:26 +0800 Subject: [PATCH 25/29] fix(settings): render Radiant shadows in glass layer --- .../ui/components/SettingsComponents.kt | 55 ++++++++++--------- 1 file changed, 28 insertions(+), 27 deletions(-) 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 67d50853..eb33ba0a 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 @@ -54,7 +54,6 @@ 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.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector @@ -206,6 +205,10 @@ fun SettingsBackdropContainer( modifier: Modifier = Modifier, content: @Composable BoxScope.(Backdrop) -> Unit ) { + if (isRadiantUi) { + GlassBackdropContainer(modifier = modifier, content = content) + return + } val backdrop = LocalLiquidGlassAmbientBackdrop.current val background = settingsScreenBackground() @@ -344,23 +347,20 @@ fun SettingsHeroCard( .fillMaxWidth() .then( if (isRadiant) { - Modifier.shadow( - elevation = 14.dp, + Modifier.liquidGlassSurface( + backdrop = backdrop, shape = shape, - clip = false, - ambientColor = Color.Black.copy(alpha = 0.12f), - spotColor = Color.Black.copy(alpha = 0.12f) + surfaceColor = liquidGlassTint() ) } else { - Modifier + Modifier.appLiquidGlassSurface( + shape = shape, + fallbackColor = MaterialTheme.colorScheme.primaryContainer, + level = LiquidGlassSurfaceLevel.Floating, + backdrop = backdrop + ) } ) - .appLiquidGlassSurface( - shape = shape, - fallbackColor = MaterialTheme.colorScheme.primaryContainer, - level = LiquidGlassSurfaceLevel.Floating, - backdrop = backdrop - ) .clickable(onClick = onClickWithFeedback) .padding(horizontal = 22.dp, vertical = 18.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -395,7 +395,7 @@ fun SettingsSection( val shape = SmoothRoundedCornerShape(if (isLiquid) 26.dp else 24.dp) Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(6.dp) + verticalArrangement = Arrangement.spacedBy(if (isRadiant) 8.dp else 6.dp) ) { Text( text = title, @@ -413,22 +413,23 @@ fun SettingsSection( .fillMaxWidth() .then( if (isRadiant) { - Modifier.shadow( - elevation = 14.dp, + backdrop?.let { + Modifier.liquidGlassSurface( + backdrop = it, + shape = shape, + surfaceColor = liquidGlassTint() + ) + } ?: Modifier + .clip(shape) + .background(settingsGroupColor()) + } else { + Modifier.appLiquidGlassSurface( shape = shape, - clip = false, - ambientColor = Color.Black.copy(alpha = 0.12f), - spotColor = Color.Black.copy(alpha = 0.12f) + fallbackColor = settingsGroupColor(), + level = LiquidGlassSurfaceLevel.Panel, + backdrop = backdrop ) - } else { - Modifier } - ) - .appLiquidGlassSurface( - shape = shape, - fallbackColor = settingsGroupColor(), - level = LiquidGlassSurfaceLevel.Panel, - backdrop = backdrop ), content = content ) From 45e96f48d2bfba528ccbf52c45e565f2ad7665fd Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Sat, 5 Sep 2026 01:23:28 +0800 Subject: [PATCH 26/29] fix(home): restore PR14 layers and boundaries --- .../ahutong/ui/components/LiquidBottomTabs.kt | 152 ++++++++---- .../com/ahu/ahutong/ui/screen/BottomNavBar.kt | 218 ++++++++++++------ .../ahutong/ui/screen/main/home/CampusCard.kt | 20 +- .../ui/screen/main/home/HomeWidgetEditor.kt | 103 +++++---- .../ui/screen/main/home/TodayCourseList.kt | 24 +- 5 files changed, 332 insertions(+), 185 deletions(-) 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 927368d4..e88aa2cc 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 @@ -12,34 +12,41 @@ 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 import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.material3.MaterialTheme import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.clearAndSetSemantics 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.LocalLiquidGlassTokens import com.ahu.ahutong.ui.utils.DampedDragAnimation import com.ahu.ahutong.ui.utils.InteractiveHighlight import com.kyant.backdrop.Backdrop import com.kyant.backdrop.backdrops.emptyBackdrop +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberCombinedBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.lens @@ -48,8 +55,11 @@ 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 +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.sign @@ -64,25 +74,27 @@ fun LiquidBottomTabs( onCurrentTabTapped: (() -> Unit)? = null, content: @Composable RowScope.() -> Unit ) { - 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 isLiquid = LocalIsLiquidGlassEnabled.current + val backdrop = if (isLiquid) backdrop else emptyBackdrop() val isLightTheme = MaterialTheme.colorScheme.surface.luminance() > 0.5f - val accentColor = MaterialTheme.colorScheme.primary - val containerColor = - if (!isLiquid) { - 100.n1 withNight 20.n1 - } else if (!canBlur) { - tokens.floating.legacyTint + val accentColor = + if (isLiquid) { + if (isLightTheme) Color(0xFF0088FF) + else Color(0xFF0091FF) } else { + 50.a1 withNight 60.a1 + } + val containerColor = + if (isLiquid) { if (isLightTheme) Color(0xFFFAFAFA).copy(0.4f) else Color(0xFF121212).copy(0.4f) + } else { + 100.n1 withNight 20.n1 } + val tabsBackdrop = rememberLayerBackdrop() + BoxWithConstraints( modifier, contentAlignment = Alignment.CenterStart @@ -104,19 +116,13 @@ fun LiquidBottomTabs( val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr val animationScope = rememberCoroutineScope() - val requestedIndex = selectedTabIndex().coerceIn(0, tabsCount - 1) - val selectedIndexState = rememberUpdatedState(requestedIndex) - val onTabSelectedState = rememberUpdatedState(onTabSelected) - val dampedDragAnimation = remember( - animationScope, - isLiquid, - tabsCount, - tabWidth, - isLtr - ) { + var currentIndex by remember { + mutableIntStateOf(selectedTabIndex()) + } + val dampedDragAnimation = remember(animationScope, isLiquid) { DampedDragAnimation( animationScope = animationScope, - initialValue = requestedIndex.toFloat(), + initialValue = selectedTabIndex().toFloat(), valueRange = 0f..(tabsCount - 1).toFloat(), visibilityThreshold = 0.001f, initialScale = 1f, @@ -125,10 +131,8 @@ fun LiquidBottomTabs( onDragStarted = {}, onDragStopped = { val targetIndex = targetValue.fastRoundToInt().fastCoerceIn(0, tabsCount - 1) + currentIndex = targetIndex animateToValue(targetIndex.toFloat()) - if (targetIndex != selectedIndexState.value) { - onTabSelectedState.value(targetIndex) - } animationScope.launch { offsetAnimation.animateTo( 0f, @@ -147,8 +151,19 @@ fun LiquidBottomTabs( } ) } - LaunchedEffect(requestedIndex, dampedDragAnimation) { - dampedDragAnimation.animateToValue(requestedIndex.toFloat()) + val requestedIndex = selectedTabIndex() + LaunchedEffect(requestedIndex) { + if (currentIndex != requestedIndex) { + currentIndex = requestedIndex + } + } + LaunchedEffect(dampedDragAnimation) { + snapshotFlow { currentIndex } + .drop(1) + .collectLatest { index -> + dampedDragAnimation.animateToValue(index.toFloat()) + onTabSelected(index) + } } val interactiveHighlight = remember(animationScope, isLiquid) { @@ -167,7 +182,6 @@ fun LiquidBottomTabs( Row( Modifier - .selectableGroup() .graphicsLayer { translationX = panelOffset } @@ -175,15 +189,10 @@ fun LiquidBottomTabs( backdrop = backdrop, shape = { ContinuousCapsule }, effects = { - if (canBlur) { + if (isLiquid) { vibrancy() - blur(tokens.floating.blurRadius.toPx()) - } - if (canRefract) { - lens( - tokens.floating.refractionHeight.toPx(), - tokens.floating.refractionAmount.toPx() - ) + blur(8f.dp.toPx()) + lens(24f.dp.toPx(), 24f.dp.toPx()) } }, layerBlock = { @@ -204,6 +213,54 @@ fun LiquidBottomTabs( content = content ) + CompositionLocalProvider( + LocalLiquidBottomTabScale provides { + if (isLiquid) lerp(1f, 1.2f, dampedDragAnimation.pressProgress) + else 1f + } + ) { + Row( + Modifier + .clearAndSetSemantics {} + .alpha(0f) + .layerBackdrop(tabsBackdrop) + .graphicsLayer { + translationX = panelOffset + } + .drawBackdrop( + backdrop = backdrop, + shape = { ContinuousCapsule }, + effects = { + if (isLiquid) { + val progress = dampedDragAnimation.pressProgress + vibrancy() + blur(8f.dp.toPx()) + lens( + 24f.dp.toPx() * progress, + 24f.dp.toPx() * progress + ) + } + }, + highlight = { + if (isLiquid) { + val progress = dampedDragAnimation.pressProgress + Highlight.Default.copy(alpha = progress) + } else { + null + } + }, + onDrawSurface = { drawRect(containerColor) } + ) + .then(interactiveHighlight.modifier) + .height(56f.dp) + .fillMaxWidth() + .padding(horizontal = 4f.dp) + .graphicsLayer(colorFilter = ColorFilter.tint(accentColor)), + verticalAlignment = Alignment.CenterVertically, + content = content + ) + } + Box( Modifier .padding(horizontal = 4f.dp) @@ -230,17 +287,16 @@ fun LiquidBottomTabs( } ) .drawBackdrop( - backdrop = backdrop, + backdrop = rememberCombinedBackdrop(backdrop, tabsBackdrop), shape = { ContinuousCapsule }, effects = { - if (canRefract) { + if (isLiquid) { val progress = dampedDragAnimation.pressProgress - if (progress > 0f) { - lens( - tokens.control.refractionHeight.toPx() * progress, - tokens.control.refractionAmount.toPx() * progress - ) - } + lens( + 10f.dp.toPx() * progress, + 14f.dp.toPx() * progress, + chromaticAberration = true + ) } }, highlight = { 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 e9ac3028..b5c7d4be 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 @@ -3,18 +3,23 @@ package com.ahu.ahutong.ui.screen import android.content.Context import androidx.annotation.DrawableRes import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.RepeatMode +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.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.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.material.icons.Icons import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Home @@ -39,24 +44,36 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.ahu.ahutong.R import com.ahu.ahutong.data.model.AppUiTheme import com.ahu.ahutong.ui.components.LiquidBottomTab import com.ahu.ahutong.ui.components.LiquidBottomTabs import com.ahu.ahutong.ui.components.LocalAppUiTheme import com.ahu.ahutong.ui.components.LocalIsLiquidGlassEnabled -import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.ahu.ahutong.ui.components.isRadiantUi import com.ahu.ahutong.ui.screen.xuexiaotong.XuexiaotongDockState import com.ahu.ahutong.ui.screen.xuexiaotong.XuexiaotongSubTab import com.kyant.backdrop.Backdrop +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 com.kyant.capsule.ContinuousCapsule -import com.ahu.ahutong.ui.theme.LiquidGlassSurfaceLevel import kotlinx.coroutines.delay +import kotlin.math.roundToInt import top.yukonga.miuix.kmp.basic.NavigationBar as MiuixNavigationBar import top.yukonga.miuix.kmp.basic.NavigationItem as MiuixNavigationItem @@ -103,16 +120,15 @@ private fun BoxScope.RadiantBottomNavBar( val guidePreferences = remember { context.getSharedPreferences("app_guide", Context.MODE_PRIVATE) } - var guideDismissed by remember { + var tabGuideShown by remember { mutableStateOf(guidePreferences.getBoolean("xxt_tab_guide_shown", false)) } - var guideVisible by remember { mutableStateOf(false) } + var tabsBounds by remember { mutableStateOf(null) } fun dismissGuide() { - if (!guideDismissed) { - guideDismissed = true + if (!tabGuideShown) { + tabGuideShown = true guidePreferences.edit().putBoolean("xxt_tab_guide_shown", true).apply() } - guideVisible = false } val showingSchedule = XuexiaotongDockState.tab == XuexiaotongSubTab.SCHEDULE @@ -137,14 +153,6 @@ private fun BoxScope.RadiantBottomNavBar( } } - LaunchedEffect(selectedRoute, guideDismissed) { - guideVisible = false - if (selectedRoute == "xuexiaotong" && !guideDismissed) { - delay(350) - guideVisible = true - } - } - if (LocalIsLiquidGlassEnabled.current) { Row( modifier = Modifier @@ -157,33 +165,25 @@ private fun BoxScope.RadiantBottomNavBar( selectedTabIndex = { destinations.indexOfFirst { it.route == selectedRoute }.coerceAtLeast(0) }, - onTabSelected = { select(destinations[it].route) }, + onTabSelected = { onDestinationSelected(destinations[it].route) }, onCurrentTabTapped = { selectedRoute?.let(::select) }, backdrop = backdrop, tabsCount = destinations.size, - modifier = Modifier.padding(horizontal = 36.dp) + modifier = Modifier + .padding(horizontal = 36.dp) + .onGloballyPositioned { tabsBounds = it.boundsInWindow() } ) { destinations.forEach { destination -> val selected = selectedRoute == destination.route - val contentColor = if (selected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } LiquidBottomTab( selected = selected, onClick = { select(destination.route) } ) { Icon( painter = painterResource(destination.iconId), - contentDescription = destination.label, - tint = contentColor - ) - Text( - destination.label, - color = contentColor, - style = MaterialTheme.typography.labelMedium + contentDescription = destination.label ) + Text(destination.label, style = MaterialTheme.typography.labelMedium) } } } @@ -192,7 +192,8 @@ private fun BoxScope.RadiantBottomNavBar( MaterialNavigationBar( modifier = Modifier .fillMaxWidth() - .align(Alignment.BottomCenter), + .align(Alignment.BottomCenter) + .onGloballyPositioned { tabsBounds = it.boundsInWindow() }, containerColor = MaterialTheme.colorScheme.surfaceContainer, tonalElevation = 0.dp ) { @@ -214,36 +215,117 @@ private fun BoxScope.RadiantBottomNavBar( } } - AnimatedVisibility( - visible = guideVisible, + if (!tabGuideShown && selectedRoute == "xuexiaotong") { + tabsBounds?.let { bounds -> + var overlayOrigin by remember { mutableStateOf(Offset.Zero) } + var guideVisible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(350) + guideVisible = true + } + Box( + modifier = Modifier + .fillMaxSize() + .align(Alignment.TopStart) + .onGloballyPositioned { overlayOrigin = it.boundsInWindow().topLeft } + ) { + AnimatedVisibility( + visible = guideVisible, + enter = fadeIn(tween(150)) + slideInVertically(tween(150)) { it / 3 } + ) { + AnchoredGuideBubble( + anchorCenterX = { bounds.left + bounds.width * 0.625f - overlayOrigin.x }, + anchorTopY = { bounds.top - overlayOrigin.y }, + backdrop = backdrop, + text = "再次点击可切换日程 / 课程页", + onDismiss = ::dismissGuide + ) + } + } + } + } +} + +@Composable +private fun AnchoredGuideBubble( + anchorCenterX: () -> Float, + anchorTopY: () -> Float, + backdrop: Backdrop, + text: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + Layout( + content = { + GuideBubbleCard(text = text, backdrop = backdrop, onDismiss = onDismiss) + }, + modifier = modifier + ) { measurables, constraints -> + val placeable = measurables.first().measure( + constraints.copy(minWidth = 0, minHeight = 0) + ) + val margin = 10.dp.roundToPx() + val parentWidth = constraints.maxWidth + val anchorX = anchorCenterX().roundToInt() + val x = (anchorX - placeable.width / 2) + .coerceIn(margin, (parentWidth - placeable.width - margin).coerceAtLeast(margin)) + val y = anchorTopY().roundToInt() - placeable.height - 10.dp.roundToPx() + layout(parentWidth, constraints.maxHeight) { + placeable.placeRelative(x, y) + } + } +} + +@Composable +private fun GuideBubbleCard( + text: String, + backdrop: Backdrop, + onDismiss: () -> Unit +) { + val glassContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.64f) + val infiniteTransition = rememberInfiniteTransition(label = "guideBubble") + val bubbleBob by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(900), + repeatMode = RepeatMode.Reverse + ), + label = "bubbleBob" + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier - .align(Alignment.BottomCenter) - .navigationBarsPadding() - .padding(bottom = 104.dp), - enter = fadeIn(tween(150)) + slideInVertically(tween(150)) { it / 3 }, - exit = fadeOut(tween(120)) + slideOutVertically(tween(120)) { it / 3 } - ) { - Row( - modifier = Modifier - .appLiquidGlassSurface( - shape = ContinuousCapsule, - fallbackColor = MaterialTheme.colorScheme.surfaceContainerHigh, - level = LiquidGlassSurfaceLevel.Floating, - backdrop = backdrop, - backdropSamplingEnabled = true - ) - .clickable(onClick = ::dismissGuide) - .padding(horizontal = 18.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Rounded.Lightbulb, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary + .graphicsLayer { translationY = bubbleBob * 3.dp.toPx() } + .drawBackdrop( + backdrop = backdrop, + shape = { ContinuousCapsule }, + effects = { + vibrancy() + blur(8f.dp.toPx()) + lens(24f.dp.toPx(), 24f.dp.toPx()) + }, + highlight = { Highlight.Default }, + shadow = { Shadow() }, + onDrawSurface = { drawRect(glassContainerColor) } ) - Text("再次点击可切换日程 / 课程页") - } + .clickable(onClick = onDismiss) + .padding(horizontal = 14.dp, vertical = 9.dp) + ) { + Icon( + imageVector = Icons.Rounded.Lightbulb, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = text, + fontSize = 12.sp, + lineHeight = 16.sp, + color = MaterialTheme.colorScheme.onSurface + ) } } @@ -274,11 +356,6 @@ private fun BoxScope.ClassicBottomNavBar( ) { classicDestinations.forEach { destination -> val selected = selectedRoute == destination.route - val contentColor = if (selected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } LiquidBottomTab( selected = selected, onClick = { onDestinationSelected(destination.route) } @@ -289,14 +366,9 @@ private fun BoxScope.ClassicBottomNavBar( } else { destination.unselectedIcon }, - contentDescription = destination.label, - tint = contentColor - ) - Text( - destination.label, - color = contentColor, - style = MaterialTheme.typography.labelMedium + contentDescription = destination.label ) + Text(destination.label, style = MaterialTheme.typography.labelMedium) } } } 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 d58f86cd..e86a2e1b 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 @@ -69,8 +69,11 @@ 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.LocalLiquidGlassAmbientBackdrop import com.ahu.ahutong.ui.components.appLiquidGlassSurface import com.ahu.ahutong.ui.components.isRadiantUi +import com.ahu.ahutong.ui.components.liquidGlassSurface +import com.ahu.ahutong.ui.components.liquidGlassTint import com.kyant.monet.n1 import com.kyant.monet.withNight import java.util.Locale @@ -128,13 +131,22 @@ fun CampusCard( } + val radiant = isRadiantUi val campusShape = SmoothRoundedCornerShape(24.dp) - Box( - modifier = modifier.appLiquidGlassSurface( + val campusSurface = if (radiant) { + Modifier.liquidGlassSurface( + backdrop = LocalLiquidGlassAmbientBackdrop.current, + shape = campusShape, + surfaceColor = liquidGlassTint() + ) + } else { + Modifier.appLiquidGlassSurface( shape = campusShape, - fallbackColor = 100.n1 withNight 20.n1, - backdropSamplingEnabled = isRadiantUi + fallbackColor = 100.n1 withNight 20.n1 ) + } + Box( + modifier = modifier.then(campusSurface) ) { AnimatedContent( targetState = isQrcode, 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 3c7fc66c..40b77149 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 @@ -396,13 +396,9 @@ private fun RadiantTextHomeWidgetCard( .then( if (isEditing || isHighlighted) { Modifier.border( - width = 1.5.dp, - color = if (isHighlighted) { - 75.a1 withNight 80.a1 - } else { - 60.n1 withNight 50.n1 - }, - shape = shape + 1.5.dp, + if (isHighlighted) 75.a1 withNight 80.a1 else 60.n1 withNight 50.n1, + shape ) } else { Modifier @@ -417,12 +413,12 @@ private fun RadiantTextHomeWidgetCard( verticalArrangement = Arrangement.Center ) { Icon( - painter = painterResource(iconId), + painter = painterResource(id = iconId), contentDescription = null, modifier = Modifier.size(24.dp), tint = tint ) - Spacer(modifier = Modifier.height(6.dp)) + Spacer(modifier = Modifier.padding(top = 6.dp)) Text( text = title, fontWeight = FontWeight.Medium, @@ -451,12 +447,12 @@ private fun HomeWidgetMoreItem( verticalArrangement = Arrangement.Center ) { Icon( - painter = painterResource(R.drawable.ic_more_all), + painter = painterResource(id = R.drawable.ic_more_all), contentDescription = "更多", modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary ) - Spacer(modifier = Modifier.height(6.dp)) + Spacer(modifier = Modifier.padding(top = 6.dp)) Text( text = "更多", fontWeight = FontWeight.Medium, @@ -501,47 +497,54 @@ private fun RadiantHomeWidgetSlotLayout( modifier = Modifier.fillMaxWidth() ) - listOf(listOf(1, 2, 3, 4), listOf(5, 6, 7)).forEach { rowSlots -> - val lastRow = rowSlots.last() == 7 - val visibleSlots = if (isEditing) { - rowSlots - } else { - rowSlots.filter { slots.getOrNull(it - 1) != null } - } - if (isEditing || visibleSlots.isNotEmpty() || lastRow) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - visibleSlots.forEach { slotIndex -> - val widgetId = slots.getOrNull(slotIndex - 1) - HomeWidgetSlot( - slotIndex = slotIndex, - widgetId = widgetId, - isEditing = isEditing, - isHighlighted = highlightedSlot == slotIndex, - isDragging = draggingWidgetId == widgetId, - modifier = Modifier - .weight(1f) - .height(64.dp), - onEnterEdit = onEnterEdit, - onNavigate = { navController.navigate(it) }, - onClick = onHomeWidgetClick, - onSlotPositioned = onSlotPositioned, - onDragStarted = onHomeWidgetDragStarted, - onDragged = onHomeWidgetDragged, - onDragStopped = onHomeWidgetDragStopped - ) - } - if (lastRow) { - HomeWidgetMoreItem( - onClick = { navController.navigate("widgets") }, - modifier = if (visibleSlots.isEmpty()) { - Modifier.width(68.dp).height(64.dp) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + listOf(listOf(1, 2, 3, 4), listOf(5, 6, 7)).forEach { rowSlots -> + val isLastRow = rowSlots.last() == 7 + val visibleSlots = if (isEditing) { + rowSlots + } else { + rowSlots.filter { slots.getOrNull(it - 1) != null } + } + val rowHasWidgets = visibleSlots.isNotEmpty() + if (isEditing || rowHasWidgets || isLastRow) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + visibleSlots.forEach { slotIndex -> + val widgetId = slots.getOrNull(slotIndex - 1) + HomeWidgetSlot( + slotIndex = slotIndex, + widgetId = widgetId, + isEditing = isEditing, + isHighlighted = highlightedSlot == slotIndex, + isDragging = draggingWidgetId == widgetId, + modifier = Modifier + .weight(1f) + .height(64.dp), + onEnterEdit = onEnterEdit, + onNavigate = { navController.navigate(it) }, + onClick = onHomeWidgetClick, + onSlotPositioned = onSlotPositioned, + onDragStarted = onHomeWidgetDragStarted, + onDragged = onHomeWidgetDragged, + onDragStopped = onHomeWidgetDragStopped + ) + } + if (isLastRow) { + val moreModifier = if (rowHasWidgets) { + Modifier.weight(1f) } else { - Modifier.weight(1f).height(64.dp) + Modifier.width(68.dp) } - ) + HomeWidgetMoreItem( + onClick = { navController.navigate("widgets") }, + modifier = moreModifier.height(64.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 9019c69f..3cda0800 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,5 +1,6 @@ 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 @@ -15,6 +16,7 @@ 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 @@ -43,16 +45,22 @@ fun TodayCourseList( enabled: Boolean = true ) { val panelShape = SmoothRoundedCornerShape(32.dp) + val panelSurface = if (isRadiantUi) { + Modifier + .clip(panelShape) + .background(100.n1 withNight 20.n1) + } else { + Modifier.appLiquidGlassSurface( + shape = panelShape, + fallbackColor = 100.n1 withNight 20.n1 + ) + } if (todayCourses.isEmpty()) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) - .appLiquidGlassSurface( - shape = panelShape, - fallbackColor = 100.n1 withNight 20.n1, - backdropSamplingEnabled = isRadiantUi - ) + .then(panelSurface) .then( if (enabled) { Modifier.clickable(onClick = onOpenSchedule) @@ -88,11 +96,7 @@ fun TodayCourseList( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) - .appLiquidGlassSurface( - shape = panelShape, - fallbackColor = 100.n1 withNight 20.n1, - backdropSamplingEnabled = isRadiantUi - ) + .then(panelSurface) .then( if (enabled) { Modifier.clickable(onClick = onOpenSchedule) From 7cb7541275b37cc58085411cac5c86250eb4587b Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Sat, 5 Sep 2026 02:14:44 +0800 Subject: [PATCH 27/29] fix(navigation): register widgets action route --- .../personalization/action/AppAction.kt | 5 +- .../ahutong/ui/components/LiquidBottomTabs.kt | 71 +++++++++++-------- 2 files changed, 45 insertions(+), 31 deletions(-) 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 5378ce0e..0a8d0012 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 @@ -204,7 +204,8 @@ object AppActionCatalog { 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, - "xuexiaotong" to AppActionId.VIEW_SCHOOL_CALENDAR + "xuexiaotong" to AppActionId.VIEW_SCHOOL_CALENDAR, + "widgets" to AppActionId.OPEN_TOOLS ) private val commandRoutePrefixes: Map> = mapOf( AppActionId.OPEN_PAYMENT_QR to setOf("home"), @@ -284,7 +285,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", "xuexiaotong", + "electricity_recent_rooms", "xuexiaotong", "widgets", "splash" ) 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 e88aa2cc..ca44c3dc 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 @@ -12,6 +12,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 @@ -40,6 +41,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 @@ -55,7 +57,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 @@ -74,26 +75,27 @@ fun LiquidBottomTabs( onCurrentTabTapped: (() -> Unit)? = null, 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, @@ -182,6 +184,7 @@ fun LiquidBottomTabs( Row( Modifier + .selectableGroup() .graphicsLayer { translationX = panelOffset } @@ -189,10 +192,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 = { @@ -223,7 +231,9 @@ fun LiquidBottomTabs( Modifier .clearAndSetSemantics {} .alpha(0f) - .layerBackdrop(tabsBackdrop) + .then( + if (capturesBackdrop) Modifier.layerBackdrop(tabsBackdrop) else Modifier + ) .graphicsLayer { translationX = panelOffset } @@ -231,13 +241,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 ) } }, @@ -287,16 +299,17 @@ fun LiquidBottomTabs( } ) .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 = { From 9a012f80319bd7c7883bc39ec6eb04d7d3b2dcaa Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Sat, 5 Sep 2026 02:47:46 +0800 Subject: [PATCH 28/29] fix(ci): isolate release build with larger heap --- .github/workflows/ci.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dcae324c..d2f15359 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -85,8 +85,11 @@ jobs: # 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: Test, lint and build debug Android + run: ./gradlew --no-daemon :app:testDebugUnitTest :app:lintRelease :app:assembleDebug --stacktrace + + - name: Build release Android + run: ./gradlew --no-daemon -Dorg.gradle.jvmargs="-Xmx4096m -Dfile.encoding=UTF-8" :app:assembleRelease --stacktrace - name: Upload debug APK uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 From 760e0009ba65fd057faa04ea1df0ce96b6bc98e0 Mon Sep 17 00:00:00 2001 From: MuxYang <3489892672@qq.com> Date: Sat, 5 Sep 2026 03:01:52 +0800 Subject: [PATCH 29/29] fix(ci): isolate kotlin compilation memory --- .github/workflows/ci.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d2f15359..6cf4ad61 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -85,11 +85,14 @@ jobs: # 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 debug Android - run: ./gradlew --no-daemon :app:testDebugUnitTest :app:lintRelease :app:assembleDebug --stacktrace + - name: Test and build debug Android + run: ./gradlew --no-daemon -Dorg.gradle.jvmargs="-Xmx4096m -Dfile.encoding=UTF-8" -Pkotlin.compiler.execution.strategy=in-process :app:testDebugUnitTest :app:assembleDebug --stacktrace + + - name: Lint release Android + run: ./gradlew --no-daemon -Dorg.gradle.jvmargs="-Xmx4096m -Dfile.encoding=UTF-8" -Pkotlin.compiler.execution.strategy=in-process :app:lintRelease --stacktrace - name: Build release Android - run: ./gradlew --no-daemon -Dorg.gradle.jvmargs="-Xmx4096m -Dfile.encoding=UTF-8" :app:assembleRelease --stacktrace + run: ./gradlew --no-daemon -Dorg.gradle.jvmargs="-Xmx4096m -Dfile.encoding=UTF-8" -Pkotlin.compiler.execution.strategy=in-process :app:assembleRelease --stacktrace - name: Upload debug APK uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4