]*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
zcmeEPXIv9Y*H2>51T3K=w$OV~L9wz52!bdg(o3$5C>HG4fK@D5Q4tXl9
z{HDgVsg~wzKYaQnE-HWVY(baYY^|N^-UlZ5-V=`cFDcbym+OlTw&%q5m;8dS-`dG{
z7+C%zc3|$K^8&|xlH#~1bE`KREL+}CU&j^+D%^u(Gj%@DjePrWQTb1g#ZMfgn$X9q
zH2=syQ}91{oNQ(fXOW%uKHtbLZ!`{vhDWrRk2!TcmB6{^
zvlCHY#8tVppzjTvQcG=DW6R8&Lh~e}H3MjCEqiw}RMqd+&257J^SHd58}4oTI%77g
zWw>|6bVmA5VbJvS)>{ho{(Z|(J=sITa
z3|ZGH*FtrE=t490>2~&o*Gl%>c=5RX<1)^XrKgQj{jg)-)%JIaoYP`88w2sPqdkJA
z(`dFv_j1wP`r8Ks+AiX9RD49$k+xyp*YgiuOkKWu&g4LDiTG~&
zU)e`k94-=1qcfPzADV^GjD%(@Xtsi8D`>WYW-Dm6f@Ujdwt{9WXtsi8D`>WYW-Dm6
zf@Ujdwt{9WXtsi8D`>WYW-Dm6f@Ujdwt{9WXtsi8D`>WY|DRS6%F4Y|*9*{$(q@$Y
z|3qmsGMkavjLc>$Xtsi8D`>WYW-Dm6f@Ujdwt{9WXtsi8D`>WYW-Dm6f@Ujdwt{9W
zXtsi8D`>WYW-Dm6f@Ujdwt{9WXtn|>R^XD=-LkG1pn1Q4^PvaL2R<}gL9-S7zqA70
z9wZ{zyDQ~0St^fV+Yh&I*D3G&(7yI7$*iHH7~yZV`q;nIps03Hn8=aFea@sOB&;Rd
zjE_4)L$>5yx1bV=@Im@BPyDLt(vwYRv5m;j3YK7**LK;msUA(kwGvx(k&rYXl^
zM>{S}6f;_zYR(Xy7SD;l@lxBAoNnzZeV;%bc#Azlsaq{Es&Yr
zn#`)`;-lk}+$TTrp>23ADGo|=+%n_*yO9Mmw|USW4EyM2h%R`2ZNWe?!=%yGnaMO)
zkCUy*az(!}-u&$HlQPO!t~!N3+kKDt`S`;{qgE4pkW7l_iQY*L2}vWPnyhRx5Z9|c
zu;HYpm;R8W)19eB>wD_859vjXu+3zh(e+_)oQ*WeYMRbAr=?m=DI@=#{9-w6*wIaB
zaZ@XokXf_$L+F@K>_&MLAu~yQ1+OSxBmQIiR5?z>adZ`0|5n6b%l%4TqPgaDu>nc_
zOx8->!1HT(465SeI2nnbI^T|ax1zYav*waWIoHufCA>}PG%d228l}@%H|Ag{x7VtV
zawD!$GpEI*-=*vVE|>_I+<@?1CM1!(W|`A6*YEU}v*1P~D;$Ebk?x+Y01_QM3^oVsKe<6o5G7Az_0ON4EpY^p9&_r`a_ay(k5se^%1mIYs
zYw$u6$1LmImKj(1jXf?$zIZVAl=CSZ+-{QO6;tG(Xu}M*X*;zgWpH0H;%rOcb)K}D
z>|MI!UdLk6G4kIVqg=2S7$20h+JLOm`5Ygbn0|U=V*|*s9vzCXv8h(Wiy2y=a3rrWDd2T{^#7Y|&@o<7fZp8Qah%a7DqC8??iW@tA4V|n|
z(TR9Q{Ldvr<$&5KKuu`dZH1gqAyj~zn(qERW_QAwtS9q&O$(E$JTA&5@KosqN9q&Kvnk%DLZZ0Q
zfqYzfAshR>vCLWU@!OlA)7^WLo)CJAZoMRFGf4mz5vdq76=@W@S{Lx8aU$gQyZzfA!2%#Q{8vcSh0xckU*j?C{
z>`koe8Fq2KUyL;ZzLN#2^TcC?T$Dlhgozzpn2%RIq8iIK*S&Z;~y+@`pYM8bx1>U
zSA#!r3QA3&JYaveIlYqkID|By(hlZ3c)+Kn!j>c;VO>Fn=hOSjWyxfj$+6r525fz1
zwj)XSLT>;}&k0I6{Z0Uw*`JHZ^NBH>!0xTb4TC^ehMTbZch9sRv{WdOUZVG@Va89b+
zq`c9m+p+-IU1F{nk(i~SegJIWvtt9PijniDtaT)0AAWUS=YOg2UId~qcetqLSlmNekToxX@W*Fd}flf_C`&F)f!4@_I(*G$kH_f15RgF
zI8l}%c_5%PW*~3DW1mQ(i%?)-B!n*M`Ey%Qq-I=TB#t=E<%>uj2i(|YUMtMNuK=|VZ
z;7}8mo#e=W)5u?hBVZPG{KbOzayef|bst)^>&5Pk3?OeaK0~2cc*NtqR>AqYUH}_c
zB9GUhZ0ONtn+nlUHi{iCBH9b7@V;q}j7YD|z-vDVd%IH(pxjk3pu_O?$;6i@Lmkqm
z|0p%eMu-iA)H+t<(>#)42=JKF`O^Cat%>+RTIfvFxBE1&9
z0N&bg_h|Cc6wxS24@niOXDeXxVwf#Su)P5QOf{Xbg0gn8k)YmyXGLVMoQ^x2L6`H0
zYHFN55?#{AMF!}XZPPnZ$4VQOsxB`(jC$1f^&z`z9m%e3cSr^XD}*`UoWG7BDPK7<
z1-k(<8TaKKZ)2cFKQqdXzJ0YZm+Uq;P!B*Z1h%Gb#B0GyT!)^YNEAO8FJczm+eCdr
z+Y+oqZ`p?lL_zt$vLJGemrb3%Qaq1)bhizWKAtIfh3-2i1rT3D3Jndxnh&rR^xD&c
zs9&fYLZZIv(PQFk6i_b|I@t1s4)h2Z@|7*c?})D_oI628sQ}!|STtNgXY5>w(l6#C4Nr^vd@LhWWEtzRq$BCN4
zDqy^fp0ips_zdy4M4=2Z1*dfo%O-gMeONZ&GhgXZMoR-uCfXgsTc>^>C_6wJYR80$
zSaIcsE^&5e(s$2aAa!<8A?CnX$Hg
zssf@TTK7SK;)S&IBSa37E?_DP(jQW<_YmolzUzcGq^dS+l*o+G0^IRW8a6|pC_=7}
z8#vYt#_!}>eHuV6QOx6EuCpWYl7L93U?0eD{{vfcmi;%Qt5uE!N|~^fOneKoKjizB
z){n_aF;VsnjxHPwp4E(hhoE~JY7D+%9dqmu%C?+o0iJ){{sPi;CjjP8p~EZMk_1a)
zqah0U<}jeauItiqpmni=ytr|m^l9TEU^UnnZTi%hJHLJ==>Q5@_JL-Cqf-#u?8Qm0So0Zzfl<2+HcCpNZ%Dd6L|(
zG0ee|jLk<=cL?YjD0=jtP)X`>ya`1aA0zh=*|(G4+3;6b0*q(c`r#!)Sn|QwV_(JC
zkmrsKk0I+gZo%|MuC!>s9w5ExdVy8rB5RcB1c>RJ9{P)@uI$Fs(9w8IKcDfLJlj{k
z($9otdb<2FJtq*vCyHK4ed&N%0zutr`?DaT9dL%hfJH-wkZOA*GjCA3wlJS79P+}A
zIG}wfB-LV8YwFfOz)8*HDQ@)mVRGF~jcSr(ZVL73@QEgGh_FH}z~F|c=a~>QLs^tH
zKm;xeUJuBn^Gqg35SY!x+Z6^bpVfG{kdx;W$k~bpd?b0LCaCv4;HA*&=0JU_h6Ur%4}XR8j6XVzRVwjvWG{h#XG<0{1-W-nT&-h|G`-rw|G_}F>6qSd9)>1
zh(5PBDV=hFfGMP1Q>Shr++$Dxc*{=PN}$jZZ}(36x6@xm-!RK9z-hJNbRx|tC&Pe+
z=!=m=95rqr&?M*$Dcd2}cf`KwP2g0=cwWw;&hT&T)N<_O5ANz7-$se>wx10ZvWR&%
z$P3w6C%|(9O(hM0i!8+Dc#f~1b%-E&F5cfK{HUjdy{&{vjeW_Zs{}#W;}zsy(+`uo
zXcW*#!yX3Ou2dRoyHbGR*T8V*=IpCPhL!F`Aq*qcwJD@n_T$C3DL1>(8IichFYWt*
zl)oUNB9LFjxJP8=$-i9gFC&26)NZr92&Rv?gZMff2%}!#W*&ru=t73zJHa_|4+4D`
zUZt)g7_}TN1WeTGo~Z?sV0$eiqJL%seI}#wnToimMt(B}7D$u&4X7XpKp{F&I-c>i
z*guoB25D{s8PSZQ6vCXT5h`kdSqkATrsVcTf+U(?9ehKWzOlEv5JipQ2||As;Ux-~
z8lVfa0dIP^vWV0WS`FT`(&axj>fTiTwe~|HcXpcBQwe=^!}T#Xl6bO2ai*nkhzt&h%F3dMFxP2nV`&N*1+(z-s6;Tt^~sXb4SPHGhxj`Zq${Fbi@;B
zryJys<};A@a*SGyXaMYuXb61;li?w{M-~%^5w!wO?M(ZwL}J7hrvsp0Ox!}0{CD8_
z>E$bD$ig5ftwHhy$uOSp)L>%aS@}d75#Sw5Jys7T?0-WaK&f)ti^#bItv4dP8sdEh
zL1w$X!0INY4XUt_pce)M%B^cMp_s}sW{p;6z&J<-E6YyL
zCQ#@T0U_zv%1KmrFM&{C*_JN1Y%5v_$)y@UoB$)mB*=u71-jhIf*vg7^0JD{2$tB0
z=m;FjU`zQ_3yHy9jMzn=w72B}>>P>K6u$Y>8~*}gSp7ATU;TXo9${tPZ1R>D87BTX
zWVA1=HVn33Lx>iLCHSm8kGokbr~_F;5iqexqNJgvf+pWm@kfees7C$)ZWo_EM6i5Q
z$k*(rR{ASpZe)R@PT;8?kNHP~x~D?^v~+O_@)uT@wR9z*hvq;4nQ!vs1%b|_;b3g%
zZeAe<0m4sYL`D>A6)YEum!8rk{5lCr+sG%{D$)|2tP=k?p!Et3+E(Ie1x=_P1G|;2
z30?`A3&?wAluu-X@Dqi<^23*W4BYxC^K>yjO&?Bt63qg%95jt2W;1_s?F5k{qHGA1
zhpl6Y9+M4rYs|X1jQCrk_549CST8V*7e#%EjrCn&z%v(4YAG=wG8zV4HJ}r5MN8R3
zA3!S5Cu<_#C#!sO12+jz4$?AP%hsI#pR8lXW83lEU`@Cvn&?V|u%iWO;p$FAl45T4
zBgFDEr56%pm6-}V39z?YX+H`6S|O7_6Y=y^t6-$8XG-z``c4T~D&t&2VgE_=!;;Oi
z^l`;m5XvJLY#FK$iiw0bSSZ5&rD02z7))N`OgyvaGL7WFY5Xt0@EuD(%U_dr?}-lq
zw>gulhza!{OqR$j#~knbjKOZpPJ@tc;QlT$y4xy-5Q5}|{$L&KjF1T?KEgH1>#+l@9j^F#MFRPps`L#xU7aPQX_L)^4{^;%RgQ;oP}KP?9(s`LfWsU-;&?J8HAtTamBtRVFrnI
zgM{sp>v(lpiiuYL$9KVw2o3U%2&^NF_x!@W6sbf-ac>{~Grg?$ewxpo?GnlQDYFXa
z9rg|}D=_h8Vio^HYJ?b~Ey~m8R>Uj#%Gw~O$K|mwnxB^}#(DqHThMfDPu!HnhJu!g
zzF{n(h4#!RZc3~ghS6wSnMz0|9&@9+&}?|f8Q#s@s`dYTf8cCr?PqZy56w4^0iCB`cVPg$bT
zD-6loGWX||XNf6N1+z6RHF8R%5*iU?0=sT%RyMNFw$`zy@$7SzD0|aTN0rv}z`XYd
zWkrQ)0|fWKJhG9Xbp-_=UIi4Ei(*y~9}!O^=pT6n{#YO2{Xc9wTYDzoBd(z*>Wf$7Pn0&vt28;zYp)`eU5}9De+$~vM5ATxlgQRzSWm_M
zG;>CXte@r<#?xsh)r|YfN0!@(7PUYD=LZUxvuy%1Q|6+a-%=nY{&EsGrv
zO0i6^HiUZ^)N$mEnG!1zh#(1v%y9EgdkFf&HUoJ@?B|3mi0}(}=+^%Q^8hOz^g5Vu
zywd@2T+70`TvH~6Qd8Aq=vn5tAsU@vaPf;z@tAj=#RqdBV
ztL+7lH9P50C=KXjnlI!ig~W1pRo;{?<;On|w`XS>@My@Y-j_(-b_&sMZp&jK9~oY2
zWH@Mw2lFt5@zxDw6a2jyz|o2Qg8KGsKotZK7r6lP4UeL0^1_so;zQhp4H|DCtDFQ#
z8@PVGn8fxyqU96(pzK#M-Vmqmq=(A(MWI7L(PQ?e1S$bYa<~v=Vc|WxCsH+LK~jA2
z=djFFUjt!~==}Lz&O7q<$Q<2sMJwiuq@^f`H*UkZckk#^(z7>Er4>KIQ1U`DxkK6xo;X`Ok9Ee{ky+vG0;!o*k*iHP=4k0`qa_G
zL|F2E4@2keI}q}Np_5%?RjCl{0a!>J0PDx*;NQowBkkzkl73ouf=<)oXHKwDW*fYq
z5Q(z>@$taBvp14E*Xt3QG~R`F?v0#W3<)%M#vG=s>#F}%BOe}E2#?N~QCe;>+3q*H
zj!cJNcQQvOSod8&D089SUnSXqix#^gMkh77Q#A2xW=*^4tz%n!RI8^0I@$8J-rDlB
zm6jv6&OKf)L74#0;R=Tf91X{}e9f95Q`aafI5fQni?Lnh>$MJn)GL4c)%e{CC-YSp
zR(P};b?aGuv=WZszOE?Di!HT0AN*#S_G&&=nba^jmz_V(WYvJ<4%ziCA$K{_$317=
zzP?Y`Z$A#ypnR|%tI9&~T{KL+7^J-#Lf8lXk(4^G%X360mOd!?cF1{~`@7F$l)(oP
z_hhTgOQ2
zm|}fw2fMYl_>RlcPr>zPTtHzY^1p~>B_E2d_ey5?4fCOXAeGcY;?(lTS$ez19gC^o
zHNe}C^x0jq3VY9duKITR;lq9E#IFd+(%%#r3h0luO<*^4^dN|--6y=SpIf~&5zYB_
zWJmX()3F?)LH{srT=l>VGMF6i9_jZ7!1
z=3V1GC4wvGE=>dR2CvEo2%COV(`ov{nseG3F(3&rlB~%l%szTF&Y^CPN!#BWwL#2`epP#`eU%6FGBfm7PyVNXU8^VV
z;`qqx^DaNNj?I~!|8O;*p14$DILo?Z-U%e?)AbmK;$m4oT
zs(ZaGb$`UrG9#h>Ne6?odzO0C5!6rReo)Sfxp0cE=qSvE@3hILu1q3Ra7fuXN$SpS
zF;?Tk-mktyi$8Yuol+st!&In$#=*$MDlNJWxLcX`=e)m>6KjlcbFt1&J+{^RZH4#;
zVPVCLG{^Z`EY32h&L9bv%j(KS;C
zLKJjhP}r87sGA1@blh_mXVoV-l+>8J1c`FEu_J#__So8m;4=W{+(7ljB{cLAW8Mdq
zZ0aifrQ$H>bI(JDbjBS>4x|ZQ4N@>GMg}`#_@z0ky3Z^*x@zSQy(5K9KTS3!0=Xgi
zIqp5zX8!inhoqit|~!jLEKUsNxzLBi>A2OOrnbFFoEXEekQboX_h?fS%e
z!P03b`n9HA22>0oPOnc`D}Y1E#PQE@7Hlqa`DIO6Vz
ziF0=>^G#BeWTKA$Q3=Yy8o{nwx<~@C^
zirEFCXXh98@*=F3Q+*NcQA}W>xv}!Wka$d&@l!Wv9`ykkY`!(yuk|*lL$8Z3p7=v!
z{fZh`p<@^sSJ!D<2NsrGIH{`PkZ!~eJw-Jf)aYikI)iI*z
z{a9tvCLn(!AUl2B?#nB6YJKoz4*x|;hk^t6YR3ufyTuFc5SxsMhS9K6IRgr)?O
zKK-iY$0*~03}!eaA0kJyfZD)%N2RZOSkL@Zj`Ya#sMvjv7G$Lr7S%;#YNWr{<*$m-
z-}Zj?;hO910^I~#9Q`kj%wV!^YTb?6WkUHdnVTBIGrs#W4jLf5vetC5MJ+hyvSR#S
zZ{x30XOtW>^1BmwgBI`j+S-evq}QqOCkzfp{)73w4>IDWi;i9GI*uJHX3$*vMUxxM
z^y#~=ZM<7)zIL%ObtTpdAOGN~#{Xd#7JgBkE6%}&QZM~JR^!h|j4)Ey;6@L{BHa7E
zcy23YH+Ok_
z+n<^+oWWej;_vtLHU9^DL5#><;lpf5YOCyJ)Z?d
zO>8#f=jrx)RCNdeT^S#%k9bBj7m5S2}GlK<^
znITsON(uMGXz@PJIC2-SQV43`o~A(h9=ADWF&r_wXxAXM5XoAs`lVYAWkDm>^sD_3
zyf)BhFdwt|CCdXe)$uW)!Mc==msMb;yW&|;uWw=6PWx>|gsxS^e5};p=cxoQ+F_b~
zZ)C3YDBRB+e);%*w+~47YCqMwJAH`_=^W$6qePa8fX&aIdpU)MRzM=0et5dH!}5xH
z^zE%rCI>Le*-j&3cRx9P4LspWor3<2M;4+2db9YdujBb_n9ZpRa}N#KV*;rHn$IZt
zHr~rjtint;(KVkY){0}U85V=wNiqrLGsiZ4+~_Xg9WI%_Pb96HMh3k!WA#35Yzi!aDojw-6jC|PMHe9&!(NUz`^aZ_@ml-J8|2&^h?
z(m!gdd-5wN&)iaYV)XJ|rqWKsRP)?n56W&+)~Wd4_~-xXZVQFeao_;r*7Qooxy$$<
z;)C(szhCyVHBR!xdUYLnB!%@g(9mnJa`~*ajWDf;<7}H>>PdLx%20OxhczR59!->b
zzI3g-!yYNo;!qfbR9w3%DlkF>=}=95=4ojFI%};S{k}7v3K*!Qw{uAu`&|Hwc(v}5
zf^bl4hW^@vexB(}xWrlbRQpKpQJ_w&FFo?zoUgM??Y*!g(J4g>wzBnT;vpA)QW#lk
z(QwRwg@uPf!hm?#V|O+Dm6tjG=NanJC*I@Pm4!-rs}U)aHVM{@?Y2SsRXqe0{s5EH
z;M2Qz*>zEYk+aN%)xRR_qT1tG9nu5s7lJ*enl58|8Tu*y*+noV)iZ%Pgn7^#Km%Mw
z)_q2L@UjF%iyn!YZ{VB{V4M$_5<|HpUn4?fEP7jOFFkA8;%9^6vE__+ryYv%`@3X}3DUD}KjL95h0B8L)O-@%A08c%z*eB^Uc?We?!Bp}#oc_&UAcWutkF
z$IA3>)_a=U8p6-T{>R69!HpizC7qYwE_8vI*(X_v!*_Y+3V+h1O^`od1mGYbzM620XRwa2hQxJsb?fw
z*%KyzqnWF_UJfQU-_|p!;0JLAOn1H&g?h(h)9F_wrK>{Ky+9a&UpQ^35-oir=8E2q
z**kdRDEdSgavi|pk%{I;2kBTb++63Ft6o%1kjwEwM*Cs^vJz?NK?udkHrZ!j{%RW@y%7fR$)uC$n=lt4?-u?XnUM*S*WZzyoC8lmQ7YZi&N;0wg1{}MU_NJF)m_lq}J
zJ+-L0VJ&(o7JJ_GD+Mgq01>uR=9ba%-2Mt6&hsX8joqBU#FC>_bRI0NIXO9ZlC>QWtx8!1dE=z;QIvb{+*Db
ziI;%Wo5DMiOhc0yC0nHBj>~FJ+ifK3<}IN6ag(nn#akrN*9`pwdjkTY{b7uTke9M2
zc$5hu8cBa~HDG6`dpzPmkIdt1tCwn{PcB`3Pc_OACPULy`IV(XJ%>x9)+F@!thwOE
zDPp)2y-o1U2^J7HCa@~K@*H2AtKmK`KcCR5KZ}hfy*}fy^IPpC{
zh7FB}Muy09|K#hKBHYyYih$gOtlgWDoXF(K^q^$~-6#?XI;-)Y&wcwCKNZqdU6`P=
zQ{yo=HzR)f+*ynzBTa
zT#)qvt9vJ|qoIdXsvPehdSf>adhF6$xvPb2rNJt5Nl5#>#^>(1&?hitoA-qu<%$-g&&>Bm{$;gmg>|A9jW3FpB+4BjwUy0IK=doBQ?9j
zK^i3srcN+`gEU65okpMCyOe(p+J2QP?Ai*{v-<2&0UGW7kVW@>!dGaTn~2f__NK3c
zm4Kx9JiI=$L=&1bLkBbKnkaq)?e7MJo@eNv?tCl;&o$PnIP7)Xzxpogigt9t<~LJj
zGnmt0F8ploMt%;vqa!a_aMCYQCo}Y7|iTZ&TcNSEfVt`OHP;EDqeQ
z@x6Fl*w`JW
z#bN8!{qeuReuM$Y=#YqE`_x}rh#uS;aRbjOV}@bvU-h#KxTERxdZ#hz-0r{Ij*MtN
zLYeA7%fpp3HU8_(cvpOJ=D`iMQsIo`tDW*9@B!KoH@Y=SHS2+)V9z_9aht)t@s2_cc*Bbu@5(4~Ph4)|hojv<^tM7}
z<;IQLl?Nt1*HCii3v(!!=`e?~NJoBt*zwCQl*cfvB`s9zuHB;#)SEX82P5mtyoCkO
zb%${1acz1+nM8dc*2~5u$ogj?7BGZ%5eWTa4}WSEn}r;VfTWrRpyM^NZ`fzx%1e=`cD4F!BH!x
z!)*TQW2^2D0tQ)TqMWBoU>CJ5%jrgKe}*c{>5i89zgkOFJGulMR{9EK=qNdzKkf*0
zl%$#q`=8c4Tz@V#cNLal>Wfk>D)Oe%S503LN815=AMp)#baB@U#-g|Dq%)CttRJJK
z+c&2&6D9W>>SzdE5BnV0i5JeNFda%3EP9f?hf!iV;`^kbyus+2No5X~o?9I6J|rVJ
zamIeE>+E^Q>TH#&qZO}4q7L3hzO7D;a#zhSeRAPTHq89c4@?J1-0ml^SM8u`ZqH*-Ab9*i-!_S;&
z<7@tb`5gw%T2e^Ar-Q#yycys!rDLptI0Zm5W$F>if~jCSe7Kr-7_Z%SWAh6y`(;l(
z(7Wf#yhG9xJLlVptUn!JGn?I4(VBUtS;HfP@M`yp_zL$F&&2Kr
zG4)QtXd-Cz6t%U*U}E?t9vq{)}${@hpoVbhT`WF%dp4;QOOE#?wi-DY8sNS?YT`E*xwdZM-I;@-HI(>sNi%`B$In
zS7dNiVvgOiK=cOI+ltbQTub72VLfw0rH-y0fIa3yw=ZtxA8?nB!=n>3-L5w`cp+RX
z_vA(?bi7l>Of5s`Nk&?As}>>B3-u;z3ajEe99pFVTV#3LMtLb$Qs+&A()a%1Q%WKQ
z_e$^G+g~rbP0X)DPA;cBsFfPOW9X3|(;(wRBA}1-$K2RMX&>I#HJxZP)6FdXhPPFR
z_KIbJ+FhwaT!hA(o@!+tj;Dd>4My76p*I%H+y3FrlsPrWp1Cp8jh1Wq>Kmx6P8!Z$
zUWY7-0!OzovWJINgm-y7?p~zHzIq&I+2-c7<;GEs3uDxUp{YB4AA{l%TaZb~t;^p&
z_3AkrXCAs*B9~S_V}Y&RH??s)7vcD
z{R&^Upfl`L`LD}*lpnT>4aAp;q3wk2WM&;OjXg}m$@E
zlb!}0>vV>?0rCQC0&0!$%nP0+(`wHJsCNYleaJls%TOEk*+DT0CffnLv-ANU^p9j9
zcIfdt18-B_*8{~EeeLr^m1*Gv&!q_3NiwlvezP^}r_j!SQXs
zD7x-?j+WU7^`Gr?_<1OFRMd*!SHeK`@YkI+wJ%c?CAbw{VCd@^1-MUyXEH64X0qZx
zTuIyN9wcp;`Naq-Wqk9xw-#}R6J34;1=p;}*QrPW7F<*uy0?9HNIE;1Z;-U}W9hf2
zx+MX11gjGZEmCSlRseBj$4b5NFJJ3QxZ%k$WcF%%5?9rTl
z)DJwvxb#)47x8SQth}nmGQOrxAch_1#|1{Q87oRX;2zt$hHov@$bq2o9Bh5ay
z7r%*Moit&l=s?7I^~p;bh7^}L9KE1#*nOl|7LF7fVP03^w~%+G%cZ4x?nb2aLuKfu
z6)A95ztK>0L(0}u%Pn^iCfWQo$AMiPx2pB`<-ZRRz`__m!#dofzDaODo1a|t{Dt%+
zU_B#o35QlbcJu^Z&0SPN7txjvpF95yT110#b4yy^SsyK_%Ka3ZpVm4+gD$n0Vn&2x
z>mTnwsn-ag7xTmq+fw!8=IomqP7`(-b*S@WRA2I5NBA=CaN%#Su~)S!GcA>B0w`}(
zmEmxPML}HL;@NCY;RvXj^f~lS_P`CQgx2Hf1P6{oQq!|jcJ^Cm#+laQC!@=(UYPY{
zv-%+r4J``E8$p*@`(S!7|e0_I*Q9f*hZn8
z8>@TDl+CZ+iubVD!GL1MI<$MWL{t0J~7pO$G2Yr4-~Un1Uuqul7%fwrVwehjKb
z^}3X&E~kr@0&r__*%l`20i3vlb-F}-rN1GR4udj4Ozqr5K~#`wlfvwtO6>fJ^S*XG
zGmNJKpz+T<*iq_H8~m|Cs!!1tRuxZtWs>0o_6B{$q)RFkAoi(t<%JGMFOcYf`GvB_
zKUG@i##Ns!l_g&fH@phHZg=l~V$N07qX}zPB^02JQ`v6b?1LRHwrN50(|9d$o}>wR|W+C1GpyriSYA05LY7Qea+-enVwS8-berckW_)o1L6CZTG$~uXqfHgRSTu)dCqAtUL#3g4#aPwJ-B4oD!H@e#ukWC?RXf
z;#8>2thv^ka%F}jAS49!9Wi;{cBmoM#0SSM|C9Itn`&sVsYa>8t7J{j2=wvEPF#n&e4;*e4w7O8?h1l!mvXwcQ%l9rC-
z)gNT?xAzmJPoMyq0`_s-3L5plfqg_5@6jary*eKLGKTzD7=BJ~+x7R969Om%a|XWVV7
z>;HG!jRNgvpMB^KcQeyz@2)V^ULXL^K(2{9b3~@1xt4GFckhf$8b}eaYyK&>7E#AsO0UnN8F%toT+(
z;48jj>7wqkZ%rIe`Zyd0MW=NVhSi?nK~)R&>KB!`CaP-wBx2MEX!z
zJ#2-adC?cphqcID-Bf2K#fCe~%$zL8^;1r*^-rO#aQ&jAI}QGy>36y+%%$@7A2f{i
zgJg31sM~)`J1Gbj=!9gFc55w#XX=@h{qPkJmX8kIR{x*rw}l!c&xK9{jf4Y$-?lIJ
z|4;Or1ChLFc})GXqLL0lfP#DJS$IyH$ztLsgZdmuXln9|+e!tblJ-+3jg^+4l~;*9
z?)=*6r{zN5jybqycjfMKkRc$;Oz=JKc-rMuEF8SmSI;2#ded&ght68ynFoZrHxxd`erPgZ9`T5L>*xd7&~|M
zoxKEl_Wwue0rm;Nw^5=_Lqy3CEO+0XXxMb{(HYC9>rcBWaUhCm42Q<HsT&-7`7cS~t-n=tJ^1?erTs-yB+pLBD-@XKxQhc;0c+dvoX!aAQ+PxXhaXuOwI*QO09_0r3XL9aU|BsMW?6HUdF
zwki!>JQ8;RZJZeJk@|_~1o>iVkQUv^I+n#=X$Vt(*o5DzF0LqVbu4Qe80-48jY?fL
z@;k&~5qO2;=lwXSstH{{9i3mlZ{mzq&l7w;ei#ip7Vi}*_^j;qg?jsd59nGv;n<@;
zs)_R%zAZ%Ht9CODKm{jo1L>gErHKhbmH|vMSM|QU4$fF;LHk#FDg^-m%4pgC5cvEN
z8v~?=6v+yLB6OVb)tO=C-|=(O|2O7aLx32Evf8qy#FmDiZ_iu0FfKvDQKdK*a!XX5oHp9WU3-0^k-M{WhdD9vpuI3gX
z3kFA2NTix*Z3a4tHc{tL#0X(Tn74Lfd|&O7j_>AK0y=RxGmG-ZOJc}Dx}d)6=g=0s
zeCQdE(ZNRA@`9RJcH#_!EWZd2e}-dETGFzlJ8YX^B{dSjd|<5BBio*AqV+zo03S0u
zvtotJEa7$YDDv>(~a#HEQe;*Sr9n6xao*VHCX
zyXNETaCTjO;6o08(8Q`e)lezf)v#5hE3A@8rwl9Y05p}38n~YzniBQlt?^|B{OE%w
zjm4V`X*7+vc&rpkSydH>Pe5{jbOZU~UmekqN@h2Y$C*Hj8PxE;y-fz;BSHMY|@A1=Wb1yNUuY!I7%GY4mo7E~;4*
zFHUQm@8{~3>ql%c>##}I)rr!;;G$w@!D81gqp
z^-erkx;9;oF9f$)zNTsT+p6;VMbGfr&Ryn=mW0ZLd
zP_Qd3;Bp6UIMc-S^dwL3aa}8W!9L*$fb3niIZvC$_jFir-MW4CL73m*3;rJ#%%^&)
z1wk=&07?U;2ovs^G|^#2s=xx|+wh;jBWExei^naE+Ox=;6yAs6J1z31Cj>QtK<){c
zr=Kq!*b>ILYX&3sH`~`VWz-OZZHL68@1-}hgGVS;mNL1)(E3z|Fj4954_mE#Gp
z&5CwSNPw}UjNbe2WdHKm6jj|tI{OlA0MKObc-X{Poa+UXL|aoh+&>j>!ep=x6$H*uw^Cc@`~M-VJN*r$n@iNz~2
z5xPOZHJS+NOlr+(mQCOpj{m9ZBHdvn!q-MJZqDq{ppjD~{4SpS4mH0_@iB7v%D{82
z$f2|FodKiRF?#3V)ESDH4)hZZ8#?pa&7Cq^wXvrV;?3-GKf^9BW1qI!1x-Uu9R_1u
z0nhG%otikq5pM<%N(#>5{V;*JC)}{tCZI_sf^mW0Ru&2NERLQh47lx*0rmUh$hraI
zM#5Mlt?Z^yB<>We>f)XyY=7A^@P?R)&p3N{de7-?&W5TC+~nN?Zt|YNqzc9i4M?Sv
zv!=_SFcm#Aan%nGzH)8fHD!w@*gl*I55x
z#~@$xW=oI5lyN6@z>pVR?O&?3g_R##;fLhaQiP6#CZQbP@pIr31AkthW7pu+Jc>5t
zWG8qfDcS-({X|O8AeB#9i5Kg)Vb?e&;&1BdkomBk&HvE_jt!9EU9b~Y79*m(qG2N@
z$Cszp{*XXM-hbp)mLLusAs#0f#g;4D3WsAml2=#$zROOb;SkXTbfsl1+U_EO}He}?Tea0JJ;Nxt?@HqOZW}d82-?ZVCkg<
zzo90hVlq@}z7i+H9cH`7Y>`P4u_53{BKPhJWpVf>xrdHk=-2S0eCZ+|NUomVP*OGhuJ@w6Fdix#`;_RPRs7ACclmwS+@MxDjFKZtm+e&
z;(3W;gF0FU=ed**dc~EoLLDvGLL$9Q9Gk=_n`G9D^6`5oRUE2Y+3`+`qdcqA$Gc3C
z*>sG99elti%fw5B`=(_j^(TXHX7j5ggU`K?{jHO!&)%S*^JlW~Q!ped1(M<~lvnZ()X+KtBNi8(xcn!+|LQzR6~AcoZAi5-?b49IJ)@~T*UJm
z<8?p$%U}Lg<^NbitnYB#pjepfnFu}8*Hj9wwb1I)DC$W}mH@HNR*~}0A??ryPkd)n
zT}*^5Yn5c!>)wRR?Ik$(0l%CRX9LGhHX?LxTFD@a+z*0Nj-7oS&&VtlW0qj80ekZM
zvX!;gn1RR>NrP?mFAb9Bg}$b|9vG?)*ND26hW}>nmkJo}qS2Dxp{oRbV~Zw8aS^Uk
z6+2)1Bh_uP|A1k&3(-eqmUVJdp|WvS)fV;$rm-hpoji^5lHO2&zcV^cO%6GT2imdQ
z1h@`^1ew1;6uz=Q4qo+@m-T?(B493rsoUq>Ewi`e!V%sPSc_Rz`ia!q$+2#5zQsn1
zL#{G$7_R*J?#ze#-{KKH)Mm&Is`~|b!2s18DgLA6&9D{o6ME>6o5L2SVIj2IUH};`
z333tjfTKYV5ARKIp573&e)w+oIF>*|H>mXG2kbxbK!za&1U-WT;eh^dgDlB~aOkeY
zPyo9`ICm{_--SlDms4dUQE@2TOy%qyS#xEVEEUi_GG|*Tej*i>ArTkgz0H9z+4zsP
zg7U|Y;axt@#1GHzuQqXOd5p}AMWON0eGGj`j_Q5+Ug6xm@rhRZ{KFr~%0-ruhR2_*
z1=eb`YrCxG?2{n(vwMwv;iy9UuUgE@l8Djmsd>SeA>dU;=eLzz89`=4R`t+}OJgu~
zwC?I$3A#j$R8}Z{K{375zX8BaVSd{DVZN-i710*j4&dKKD_l16@a96&-}&aNeYp7!
z(h`e19k3i_NX`E7EAR%W8nl)r)B$pea!%Pu;5kP9Zup~lGC5bns*zO*ydT~1vAi8!
zo-On%l(4|IWQkp9dRNYQH*j%uq^&5Jy{_cEm`BzbdS%*zrMO_ml+
z7u0p)hMi@9_$v}ZZ}+(1(PU~zt_VlMJS8A@qpX+b1p-!2rwoEaGptQ>ZBlY(P;*k&
z7J`p)7Y};LSC+V`NL!tpXllc8oVbFI5?ad6S^-KW8R#dtxubl>wfkP1Y3N60-=y>V
z5|{rJ7_T5FzSZ=IxWlpp7y9_OqIf72Vm>p*>(M7#xJ{|CSM4YVKg+M<2k^`SK1IBCCs13Qz>Nshd+>QiSs
zxa#w@AepqA#C~rJ|LDlM+cxV^?R})bAN>1UVDUe#y2Ger4IBc6(HN%rt$mmjuh*=!
z0%_(XNbooTmxnl-#I24wd#c95(_or*XVQr1jd3}${?hqF`?K<2-BXb2B$r6uc15lLr~57xomW3M0MYwkWyTFeJ}x1xx>It
zchBgu0r&kcEpulce+Vm56h>w0KuTTgv2EvX?zu^b_h9!J!Q7ha%59E)_E@}2Nf86B
z`;A~d$aJW9KVj_-ZHM|^om+{C@yj_EL7%IA44UBH2-^g1pnKznw0ooOJ@~_U_(riq
z2R|7iJb!f3?K4lQg=)c0h^Q&|-!~A4?RO&?-EE{ND#^Z(K$4s-@Qnl?i&y1^5VRmF
z1eem~)zlt%312u8TXf8-_%bCwZHP}#Nk^#gY#@?E-3lpSvaT5AUR}R@2;rrTO17^R
zc>bV>-3|D*z%VZL6W8E?(_h6mK6a9Z5U3rD@JtI@y(y?B5iMaQzYciczUMrk(S9M;
zgYun+_Gy5rYnF!6<3UpgK;UmZGPy;4;D>#_s+F^EP;HrEnF&5RhhAh7Pubmy*T3AI
z=fr?nK!aK5$JZ=PWoDg!M*!|9(K0v!yL{+tMAo-*UUK08fEGusK=aXg*sO{x!y5!yPsX+OYF1`&+nlD@K4Sk<
z4cT0~F;mFG^OLJbFaOR@uzlcBHh#8R{_Q{Ts!Q#*;qnt3c^g_66I%T{hD78^A%-0rvmfSjHMxZh`1-M`HGT3AzY#Cd@iYc`7<&UQ_SsMrQ
zX=fMhXG9tU00a@*`C9=XHN@)NO=qhSvS%ID}6!E*cwLJn(|6&kcI2rS4sY`qa3#1
z%volRAAc_6#Tp<@R+YK=5UzG!wwptc*ouG(S&sgl_!_Y0v$(N20*
zx6DtG5fERzkAy+OiB4vO9Nrv8^pcH{x&s`x$z|K|1X61OJB#(SQ&dBVyv+N_Vu=m{
z)Q=t5iU4}-U&c<;UxF34!|&r4;^k&0{{B>KiB$w^6_S?c1Bg-=B|wEzlV1Qb
z;TB>#ePjP?Aexie`VFWVI#BUCLfhX^%FNS8`+A#dG%(rvvyS+tf>*`u4?%l|%sY9*
z@w$i5Ey@ng6TYn@7DpVbKG@J3|D>w7I*_EwmGq+rgA+CBzZta63FfJFIxeE5_s(GO
z5~l-d1XAlL5a%Gz|IDQfZ*>pgn>ugQZ0eL{U9bvV=!eJ-6Ar^)c^>Gb3z-jSWIY6hR-stK!POu%4bje9+MaQC7B#|4fh-PY*A3l>W2VfCI+*SzWwBu;y3@
z;U|9>uTFXop?E&q4yD!oy9T8gerIAkRXg}=Za!8p2G1G)u`A4}~IU+~|6%|Knj^Bu@XgM~AC6}${$j@tM+E~n&goQ%1j#O!N-zmU?ktYUXWENQpc
z`8RPmQRpjXf@F7xBTIngXfNKenTPSX*tjt3eiN?B`c*vU;~yI8i_`-jRh3ZN5js?r
z1&5(dCmo0EVGX9Vy8FlZhw!XYhtavCjbAo}S?drJ?q}y)g^r{pjD(KfJJ(6?l_T%B(PMXk_0grn
zz7QV~EX9uM-n-ga{1sMz5Dj{}wZ@BXwrof^#OLY;_8EqVi!*j^Ac_e(fKExedxzw}
zLwuv$LqWIqHvD*l?4bvk3DxJ*Hcn^|*-68B7w;x#5MTHg#^Eb>7;laFktL*GIT->*
z$5pVBqwv8*+3`y+hIB5bunN`2zMc-v5n=}FlI>X)p~_s7OU#8&msKM4_TRs4};=^t`-ldlCGdlW=}l)eioJ1
zM6MuS6o+jy|HF~<{Z*YtJ~vw1aN12)d*nJxuex3UDR1a-@Y3#^l6w=yfV2g>nwHjU
z&>l75CP7`yV|Q0wOhFm}fP|dPBjo+_g;w0ExB%fVTtZ?`-n=jW-jS6RnOyPOw
zr;ZKM!Z+bomV4elO8OED0>tKxlFkm%?u@ccE8z6pzf&kXLX0Q!QerXwCs`~tzd^K6
z6HkNJ6k-&s3PNA`Pcj9|EWuoDyT6XU33)4O%m`W6Kc7NPrL`@$(z%khLB8)ns;R>&
z=?>F33yDTjufv8uU?agr@0<3XT-(ji3H)Zb{=36b{4jX?ZShO5Cb-;D{0QlnHlU+<
zjIV^04~(~j#gc5jHcfh}(*yOp?;pISQmMv<1F_qE_mhdOK^%1brUZ4kL*bHEVK`*E
z#}9O>Lu@S`&;mR)?OLW0BE4m!T0<=Su6DYM;@C%fLztS`F{5@WZYaX_^2;;1R-jFlhek!+*U*LW17=IjS
zv)_aD7+-#$LPdQtM3Zvn4Ez>Xl2oOCsWcUR-ZX_v<$9x7UCs~5!*f$e!@H*(Fh(D5<^%7bPoh_Yz1=
zB~kwkYmj@0&usKrqW4=07QfN}a>#S(e&n<>UmXl|_Pr%FNlCO1w&*1{`Le>KBI96E
zcICz`az}TxD^e+0woaN%2o7Pl=eBtBf0P|}T#Wy}d%n+cSLoWLZ%%s`G9o#vMG2){
zXO=>d5mNWqG>vwY5m9s`C920BiI586%uwH)RnhvrpDWy|?)Mk3?(Q?*dw>3q7~M+H
zA#d(kFBX4$vvhoTiXvP
zvBqo>&!Y<}g;}l_)SXkHwFUVFF7BdK=1f5Rh`M6|{DiwmnOo{IYL;t|_zQ^&R493@
z==>f;k`np>-XC=S*@R(HZM^WpK?SbUhI>yPg}&7@@p480x^8AI7jkwf1B~cxlepad
z1OSdAgQRT7yDgX0KgGkq>i<^mbyMgR_!@vj{aW6H6l^Wxc`@_U{}D>J91f=6*0U3P
zphU(-EVEsCt>g@a1^^LME9VPO^)=fErr*yy$^A|fEIdGqJO^={|?ghQ7KtU
zJbf@0kaR1x)K6FSa0?Mc(PP1mJm~&utI#JO3_;zWKl_=|!0Q4CLM@NJ;Iv3SYN97S
zIucw+zm#J>aK5;?%(5p5rzJfps|O$IkDWJr@00^SNAewuI~iX(dKjyVqa?ci
zIeFlC?La7y7W_KyN_e{aeeK$K{56?u$=*E3+Fj^dJ)SfU0%nuyyw(UuBCmnEkDV|y
zo?u9P7WQA=44?nQi2}Os5JUIBWzH>K_S--^8%fW|pl=6#A7zmxoDocN1#?{O-K96x
zMDSy~BA27OYT0f{Wi&kOwYhW8@gu?yEHITphPdmTngyVKD_^rJ|A^#|5>?oMRTg{E
zy9Z`w=wRA&M{STClCuDilZ;nP62UqVg!{TLJ=u$=j!ES6tkL>CID&%o!lQ((5Pg-M
zq(K%opt>=Fy+YHNqiSy{N-R5j@R#VpUVx#7w<}F}?T=^_UaIlv#F
zcK9+ryqqACw=_FV$dB7AZysMo;I1Vdw-%<}o;SH`1a{N6?}TX}dVKiH0_wPOohnOb
ziCes7sX#{PauGVl{~-)f?P0;^$;3wIRBE0E&F?QvY~l|A5TH#3;c-LFxkr=ml5+6j
z@<5ebNpS=5uS1>U;$IvU!Uow}S`*3(mfTRNxnbyjU19;k$zyRk{WM*BMB^DWEkGeq
z{iLLviKT+<7)yFCf}z2-K=5gA8QVY8E#M98GDw{Jg_
z{sZHjo{cBukJu!!;VC|f`dW(8;P@*ihV92TxA1mAJEl$<4L^lB~z(n}+jH~@m^nYhpH&RYLF
zw*z)dVMEAm2dv&y{+P+aKOpsu>2HqQcj^k{`7k$j21-n-BxDT;=K2jDyvNrLs&IYh
zZ+uZ3184Ww=6wD>?`#zx(Q{6+IuERJxz|I;@3*D#SH=drL5$2SUPe?ihZEkY!q3R)
zMB+2BhIe?2v4;PyB)Ik=h!jqZo;pGp#MmM7*3KR3>92`JIa~3yuRX-q7Kow_{GnK&
z-C?`D1h?YfB2=G!DeQ41|DSp~A@4h!qaXq8%mf-Fx^`BRhwIC%x4py5?Z!=JmQ`$3
z|D)de_)kVGaP6>l7klJd3v$|qeIcidBrLmQi~MwP4=!xxpT>3Z0AocP=H1$F-V2li8
z$qH8Sx@F$V2TL(dum2>R{OAc?1>lhcDR}(eS$x$`$FV))QHanO(jZ+F7gq8HKMc@dC(a4mwJ~nK{-18>jBLE9
z^>|1%sfHMAi55|pJzsl%M337G9ecF`?bm1PpoN6sOY&m0M0_2~Y7*hxVQ5(Rchap~
zkq?9p+bt5~>_E3ZGXKE~v*eST=CtWr2Oja@y&ccPI6e?t(>&|@(6AMRpur}S)uhZm
zaP~{s--jImO>2|sC0b}tbHIm7Jg;R7aGD@FK%v>^sG{F
zR;j5@hzOdYz;TC^dG{;Fs9eqF%0mK;bp-eQR$RjW8K)1GC-QgSiAQSJzSx}0#h`Ri7xR1TuosE>%#N}1bDdC5{@Nx!Pa@?S3aW<*&LA&no6Li?O`))f#
zkDE?bL%2_FDG{H5doX6jM*|4~i>rY;2)XN?D7t9(Uof(mu0jzs@IZVGcq?|Sf|4ky
zTq6|WupxeGJR1w!ji?7JjW8CD4D^62>>`|T9r?LvcE>D|HC1X!@;NKuQ?o>TjO)cB
zQEC=T01Qje2BD*7_uLE)+4F}B=WZ0GWAUeC=x^?PK^6nWMrzK+J^L=%d{{w<9EuL-
zvhp(e9mz~hdMU)uI)>DXwt{mkfb1>(mP3I*7%`^o{Fhe-iP>B%>(jq~p4bBc6H9RI
zm_JuzFC~p{LtOOo@3^QCSp++SIeh8uY@=#|A{hQ8*>e?dW~&K(tB0c;fs{$?c#Acn
z=ZGT2(`?=2Jb*&pw6(Q=gs4^*i*_lj9z<#Nf3?DS13siW-9bcr?jFvFHiGy2@>__Q
z#%Fr)c*Er29cfPDm!cRWHypr^O%K!cq@^KBXA_ymp%ZddB12*TwOFE|Bn#K{DE
zb%wI32xo6sshRO5efzNR-Kt$HttTg3I`vV~Db00YuHRpO;cw5(AILFQV{6vEnEHLl
z37$nzB0Wxulf6U21X_|4gBx+>P@rftG*cCbVdkiSXt)V|8|xpbIZ?9}1n=4tJiH*y
zS9(4*h(`P7M7^|bUjlyfqzm0;$Ke4r%{8Be0SN6c4+_#M-MRUO=n#fUb30%hu9
z%d-NMB&$8aD?v)njrIN3gVwRgifHQA^#gOR$
zPNok@_SkyA+z<)wO+sD)DP4`V#l)<-oggKyW+ef^In;4d^vB~yOB7Ot;`KwFU;MI&
zA+iE@fR&CAoOy2738HjpJ^8%Eq6rGFwswdXTnS*;L)yq#r9jfjj}Rm8_#Gn`7PmaEt?WO;Ufs!cP
z&=*8u?7J^w%Rn|$#-a3E^!*TM#_Y)F?OLlO1cEFCttPRS5yfi4i0b|eW{%8AaWt1K
z_s$p$BE;;7lUP4m1%>JpU!UL*-VCBz6|PiKkUzQSo%#8;Lzmw2`II=#$WR9Tu_*bd
z6Ln}0G{+X7+x%EEc4rI#q8}XM#LE;21M@%P?(@&T
z$>+IDmuOthdhE4?17o2`rcF$d3=cfFbb+42Mm;7x01X*n(XXp3#f)VT)u8m95SWUa
ztS4@g9A<(HB7L~BVlLojras%@N&Yc$v@Fas#1v9`OOH(u-CsXRF>;xAMl6Ad(%@3y
z`A1Q6G(|r_Db56o?sI?eXb$ly*8{)$K0ne`qJz!^YMS)?)@X6*J9t}fhg4d3xptp{!
z)sVQLW8)#h$v`pVDRKd#gd^@R8K;3ly=P^M`r&_>D|8BT{Q`xJfBztEq2I*MjRKQ-
z)V;Oh@Wa9*2o7Dk!H`&s;X;y4=nfqT!cWZlkkaP%n9}CZq(1Xj$kK;UQA$b`KtVud
zi3Ke;1Hw;RYR-%OGCmYY>C)vdhJ@rCr_DZ4^i;w`-)ayfpREvbZ{YIRV4J%?J}i#m
zM1SV2MEjL{XQ}wtubfqYKl9wZA8&@ml4jvo7nLN}OERIDNe61ewr4sM7Ze8gF~TP4
zF2X7#LWR$^Dhq!{^jGi~C&(O@mYh%$%XTn&WvbLcr}IQo>Z|m!^`4I;8mc<3$oG`P0y&)!&v&(*GNt?U}CV
zxuEJ^JozNv2^>V?suLi@43TH?skIVWc&uImCVwbA&YgL?HtmI2bo}{J_kX6zLDX#|
zb&UwZiC=RpAl0`j?f%MVgtFF0EyqdfB+drw_K2gkw-`!-8D-YsecvDVj2JlUj;cw;
zE#CV}5?Z1_k@~OzwcTO`3BwiGbM?CdUd4@k&GwV?&WPPyc%#iw$dpmEuQ7doDlq_Kpe2X_zFvllCx
z7)-c3svh$Y_MlCJVA14$62-Bxl>{Fp$J>kLBmVTjH6mG*vp!3bWD%VWRL^;($4i%r
zfb}2356PIXPfTu>m02eH*-v2nlvp|>0DKj^2HCMA$>3jec0fSXT(d;Vd$IHL&eEkMLHJ1A^#Kp*n`H2AJRe8`C=HpmD<8AY{ccQy`bLOqjr3m*?8n)HH@#{Omncof423hlvdQ9u
z_f+W?)24wIW2Os62C>tK)vOP9f+X?su>^>27_T|i!x8d{wt!7l*1S60ehOUr;APA^
zk~mQ~P<*9;o#5VE-(*Z?gu-Jj(!rH9%3*
zC
zQHG+D5_q%-Bsnfiig?gT|00@O5th{8%*+6hl2BA7KBA2^z~ygQ&2NWy(Jn&7bV)@2
z4-$k-xp6?IQ!CDk)hbdD*BnH$owQ(}Jl*UJEUKekQq?P%y4dFwRCajw7_nWV1eSl!
zKB5Qg3Aw6a;3r8eU#P#~_F8{KKp2wEBPC2W?M(;FcUpJXW@Tu69_|d-y2vl>H
z&GKHq8>PdJn#bNC0d~+|K%dR&m~;g4$bdueG-X02wPV?R6WABoXz6nh@KvxnDd=
z1{#%o@1^|wH-qT)6Gp=(wCcIdzD;k5Bq_E5>4=#5!|0SqiKQs+Y+?EICHqyylK^&`
zFsYv4`7{2nBCKklfZS&n{uCYk(MVnYnI;!ZVeGfdB4U7bP|Oqj;_knqHxm8}<_u|#
z6E{u_L15r}Tl==ah|m50-FsF961UNgj+qATfluK|klljHG4whmncAN@Cs!^_Ak+rt3l1VzCW==BDFjDh8hC
zF-^h$%i@5DbvCJIH;5eS4fR}0ZGa_N33NSE4FD81$}nE!6e2%E9c3sn2X#USJ>e*0
z`GXt~aXei;dZ_3ZJ9N2~er`Cyft(TmPvcMz32FjpjtpD#T3f|wEXEYOpJx=mNc
z#wUm@AB}E^NO+uOTn0y`XTtJxVqA!EFQT-~{}((5xss9R=<||zS(f&t>|sYo#MghB
zBD{|$A##(dRp-um3`_bGbynv~=84ze0w$R`bd)Z0Tt;M`+T;emn(^?UOvH_MvZCjD
ziCVXW)qCD1+Eww@kcmlJ@m5v5j^y9qEQJ!fgqLlAwu-l=dlPp0{H_JC-sls>9W<9W
zUjMEMQSx^oqG=u0@mm7kr$)88SBKcI1?2%F7}Djw=M_jW&w#U+MX%EyGZtb#oO6sD
zrc*X^zSPkOxpl-DeX;g#3GP*&6N5eJg$?z_rP0#FJ5
zaHLGjGNW&+e^|5dEI9k%HLE^}+)#vY=SWk$kEvxJgL)?ePFeZQes7+vzI)q6_|ljh
zIm~*@>&nWREXG>k;zjA~j^1HT+;=#-)o{n)eHv7ntp;aCd3FDf>(@NLcR*zEEX2w*
za{Lg8_?cw`k1aWHsqe%AMng95AATc)f}lEb-0AO&wlodMu3tJ@m=+kbNa+WDR2C40
zKmSKLM=XWnV0C4g0>NUgcw*wSQaQ`&r$e7%_&Th3|LaKnrAwP&5Mdn}7PA&~C)_Se
zmlIODAz_|)14;ndVT!c4Hruxd?BguQUHr}i7
zI4O?Ij$wVGfS7rN4u94)^XMfx%Yyc@EVI0k=HdL&{n?bqDSTh*I>oX
z6ir87-BN`+t`aptmR{3zu=H}j!)ijOrx&yC*FB7N+X4X#V)dR!((vuE(~urIc-o$M$mbbY9VTE)+`PTK@1mP&
z$~5^X%YXCb=y7&<;3-+2OUlA#&R*%3DASM3{ORxl5&&oGkZn`?297$iIbvev
ze|9W93Mo;vd{cSz)00x_4bh=hSP5eFxt=$Xg=r&95b*y0JEE7WY1rR=AF
z=#03q;`;#)BTt#JtP5$!&uh!yvb?Q_r)m9-#^Ev~*`LL%FN9onRIZ?ejm&zrU8Y8J
zfbJOKRIw8S-&BfB-wzRu`Q%Gd^kqv?5gHUBgD4u;=_SQwArLy}IIABgUB~LSF7stv
z?h*}Q0((y_>_6`Q)0r?-RGy2le10VG=G5>@89|eQXLXWF&Sxf$BOxmAh(fW{^Zi%J
z4cglkQiZ>1tHZHkp507MB%)G2QgO$eC3vK&i4y7-6q^zPsASJ#T>We{*6*D>yWp0K
z=>uWLT}ZA~P^%AIji4K(LrIML{4>o(2&?OG`&GEXK0oDW#7SWcBqi)0rKx&w_z~g{
z-_d<|E*zd#G7afF5mY4|N)I}<{|y{IIEHoOMTlDT-Whnfrfcyu;cA(?a>2R5F?sIr
zF2wNQ&wGv;{kDgN#qb~Df!#sWuVs6AUw~&hXER0xT|2q6$0D69dT@5!R<*wQ;UJ>k
z_St=%y8=D<0IPpzZr<2$Kai$>tE(BP&_Up3%x2kz)i$f4y-bPW
z%|AFY#!Po3wZ^bB(uJfCVIQq;t?!#CZWHKhb3
zD;`2Jl?rWqO%ylv0TzTL`_h}W!X80AWc=~v-r=`9mD!!a`ozz0L(I4Atbbud<{Q2Y
zOg&n=#jEbE9;_Xv6g=s5_V)C?seJ}&8M=;o(GH$BgjYq+ul$nPL1)T-#a|J{LIOv`
zX{2V&9cQ@5nEctg>B7&A$-wbyeZvxwrQj1%WqEMI)y8@F0?F?p28PyM6|
zeb*m5b_Tv`6r7uuRx9!ie$Wu=1RIQ~7e>zRKbz=oU}!E+_bB_G2Z#65v=M;(pK~$P
zFwZonX?>SNIxBE-@BH0&c1U3#K=j1=4F6KD%vMP@USTDI){UfQvzM(c#DJ?#YAA_S
zFRWlhG#_o8H-2^P@qMawzAl)|#eN
zpA&|H2qiE^kYt}K@!q$v9iB0J%=Bc`!W}=jXk_6>@gU_mW6w?;86v*Ggnv7Kea_^I
z35ydUGNB?J%I%yIHg|^-dl*&lxyvqE45t*{{JG^?fB1ciAqcYw{WgBLN#xW#BrTKU
z#ywXyzDd{wY9xmmbs6Ic`NPHng&Tr}`OlN0U-7zcJ*4w{c$UiN_hsG>lJ!ii$m
zj)tec2#gmmWH2-v$RCakFlEk?|D!Y$DQzAJ*SE+gR;2!Z9ECjtFTU@+%u$1pQHU3Z
zhCl4vw{w(-OW4J2CxR{gNaeXh*=kR%@qfdnKWtt^QSuF=Fn`mDa63NB00)7LninbS9ah{9Mw;MwR=tc^$J+Y{5?5ek?#EDefC
zU`rV1)8x4<@1p@bw@G1+h^p5F7thmy!^upXW;bRG`CSZ7NcQg9H#Qh#}rb@EE6T^=PA_
z{Ztx7GXonvQy-PL#b0PSjo2z8s|wxliItHu%nb-?q5>vJ*$m>n%HDS{g95%a-*UAJ
ze3DL@beL6a*R$cLL8;Y1hxhC2)C^`6tbC(Tm8kLwL@irNt#6Nbjy5zHvxibMO_jxbjVe1jqxpnTT`a5vkkrid
zz1;#a+LItKop_I(2`?xhHi^O7rvqm%v6H9M+!$|`a$ZyZ8%b*2SN6d{FhmH*&@<%t
zEPL|MK1vGHMVfWffA&3j1FW0NMcvj7**rDD^bER=Qj^}m2!U5!h4IV#ON&l$Ytuzv
z@vO{u6_^X7o+IOxW_PIFBVqd0n_9~r(O|&kp-vcZsSU<{$F)phxdf4ysrbaFL+-qX
zgve%A=frVSl&*<>N$4;0;5nle+NN=0S;e=9Q9DITd~q0w3d9M$j*YRYWYH*
zPMRqv==C>;=srU+gQ%f}jkNTAfVE&-keoYO(!AX8j-$aVGev>~wt5_&6;}>Zlcli7
zgi=qrBd)bcK-e*JSpDm7{8A-yhwyYh0shV|8RFdGy
zjutbho6MM6=rMQpCN{#&lP1-rx=1q_ZMO|J^R}
z3Vl$~R=}}$i#!#A35SuC$&t$QJdOjKrCsqSUTG0Dl(E}LW2EPs5Js;IrnSwL#uY-+;#NTrQ);DkT5
z?bmmvBAgP+ZtTBH7Vc>_opGd2amhX{CH5pck8$%wSeOS3h79HzDZv|gI`Gch3XSt8
z;78nP_|?u?PGQpkc^efIg~MkMm0d$>-jVI<7LINp45`?@AIj9B!0O0ZePVTM#=<&q
z*z%RxHcP?J6*=}(YN2Z_@&2BZBQ7I#C*FCAFy4i*YUYVgU6Xgc972hh4XHdWRy|yk
z>w;Uo6YCNeQ3``I6E>i6qbV&