Skip to content

Commit 04a8629

Browse files
Add Android simulator and release packaging updates
1 parent 5ea6e03 commit 04a8629

36 files changed

Lines changed: 1972 additions & 825 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ shared_grades/
4646
!manifest.json
4747
*.md
4848
!README.md
49+
!AGENTS.md
4950
0_build_and_deploy.txt
5051
# Exclude specific JSONs but allow config templates if any
5152

@@ -71,4 +72,4 @@ public/dist/
7172

7273
# bat
7374
*.bat
74-
*.ps1
75+
*.ps1

AGENTS.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# AGENTS.md
2+
3+
本檔給 AI agent 在此 repo 工作時使用。請先讀實際程式碼再改動,避免依照通用模板猜測架構。
4+
5+
## 專案結構
6+
7+
- `app/``server.py`:Flask 後端。`app.create_app()` 是主要 app factory,`server.py` 只負責本機啟動。
8+
- `frontend/`:Vite 前端原始碼,進入點是 `frontend/main.js`,樣式拆在 `frontend/styles/`
9+
- `public/`:Flask 服務的靜態入口與 Vite build 輸出位置。
10+
- `android/`:獨立 Kotlin / Jetpack Compose / Material 3 Android app,package 為 `com.clhs.score`
11+
- `tests/`:後端 pytest 與前端 Node 測試。
12+
13+
## 工作規則
14+
15+
- 優先保留既有資料流程與檔案分層。UI 改版請先找現有 screen、theme、chart 元件,不要另建平行 app。
16+
- 不要把帳密、token、cookie 或正式環境 secret 寫進程式碼或對話;使用 `.env`、local properties 或本機設定檔。
17+
- 此 repo 的 Markdown 預設會被 `.gitignore` 忽略;新增或更新 agent 文件後要確認 `AGENTS.md` 沒有被 ignore。
18+
- 文件預設使用繁體中文;程式碼註解只在能降低理解成本時加入。
19+
20+
## 常用驗證
21+
22+
- 後端:`pytest tests/backend/`
23+
- Python 語法:`python -m compileall app fetcher.py server.py`
24+
- 前端:`npm run test``npm run build`
25+
- Android:在 `android/` 內執行 `.\gradlew.bat test`
26+
27+
在 Windows Codex 環境跑 Android Gradle 時,若 `java` 不在 PATH,使用 Android Studio 內建 JBR,並將 `GRADLE_USER_HOME``ANDROID_USER_HOME` 指到 workspace 內的暫存目錄。若測試一開始就出現 `could not open ...\jbr\lib\jvm.cfg`,通常是設定的 Android Studio JBR 路徑不存在或不完整;先用 `Test-Path` 或列出 `C:\Program Files\Android\Android Studio*` 確認實際 JBR 位置。本機曾遇到 `C:\Program Files\Android\Android Studio\jbr` 不可用,而 `C:\Program Files\Android\Android Studio1\jbr` 可用。

android/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ build/
77
local.properties
88

99
# Android generated artifacts
10+
app/release/
1011
*.apk
1112
*.ap_
1213
*.aab

android/app/build.gradle.kts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2+
13
plugins {
24
id("com.android.application")
3-
id("org.jetbrains.kotlin.android")
45
id("org.jetbrains.kotlin.plugin.compose")
56
}
67

@@ -28,7 +29,21 @@ android {
2829
}
2930

3031
kotlin {
31-
jvmToolchain(17)
32+
jvmToolchain(21)
33+
compilerOptions {
34+
jvmTarget.set(JvmTarget.JVM_17)
35+
}
36+
}
37+
38+
buildTypes {
39+
release {
40+
isMinifyEnabled = true
41+
isShrinkResources = true
42+
proguardFiles(
43+
getDefaultProguardFile("proguard-android-optimize.txt"),
44+
"proguard-rules.pro",
45+
)
46+
}
3247
}
3348

3449
testOptions {
@@ -44,7 +59,8 @@ dependencies {
4459

4560
implementation("androidx.activity:activity-compose:1.13.0")
4661
implementation("androidx.compose.foundation:foundation")
47-
implementation("androidx.compose.material:material-icons-extended")
62+
63+
implementation("androidx.compose.material:material-icons-core")
4864
implementation("androidx.compose.material3:material3")
4965
implementation("androidx.compose.runtime:runtime")
5066
implementation("androidx.compose.ui:ui")

android/app/proguard-rules.pro

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# ProGuard / R8 rules for SchoolGrades
2+
3+
# OkHttp
4+
-dontwarn okhttp3.internal.platform.**
5+
-dontwarn org.bouncycastle.**
6+
-dontwarn org.conscrypt.**
7+
-dontwarn org.openjsse.**
8+
9+
# Google Tink / security-crypto
10+
-dontwarn com.google.errorprone.annotations.**
11+
12+
# kotlinx-serialization
13+
-keepattributes *Annotation*, InnerClasses
14+
-dontnote kotlinx.serialization.AnnotationsKt
15+
16+
-keepclassmembers class kotlinx.serialization.json.** {
17+
*** Companion;
18+
}
19+
-keepclasseswithmembers class kotlinx.serialization.json.** {
20+
kotlinx.serialization.KSerializer serializer(...);
21+
}
22+
23+
-keep,includedescriptorclasses class com.clhs.score.**$$serializer { *; }
24+
-keepclassmembers class com.clhs.score.** {
25+
*** Companion;
26+
}
27+
-keepclasseswithmembers class com.clhs.score.** {
28+
kotlinx.serialization.KSerializer serializer(...);
29+
}
30+
31+
# Jsoup
32+
-keep class org.jsoup.** { *; }
33+
-dontwarn org.jsoup.**
34+
35+
# Keep data classes used for JSON parsing
36+
-keep class com.clhs.score.data.** { *; }

android/app/src/androidTest/java/com/clhs/score/ui/ScoreUiTest.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ class ScoreUiTest {
114114
composeRule.onNodeWithText("上一考比較").assertIsDisplayed()
115115
composeRule.onAllNodesWithText("班級平均").assertCountEquals(0)
116116
composeRule.onAllNodesWithText("校排").assertCountEquals(0)
117+
118+
composeRule.onNodeWithText("國語文").performScrollTo().performClick()
119+
composeRule.waitForIdle()
120+
composeRule.onAllNodesWithText("五標落點").assertCountEquals(0)
117121
}
118122

119123
@Test
@@ -211,6 +215,7 @@ class ScoreUiTest {
211215
val key = cleanSubjectName(subjectName)
212216
expanded = if (key in expanded) expanded - key else expanded + key
213217
},
218+
onOpenScoreSimulator = {},
214219
)
215220
}
216221

11.3 KB
Loading

android/app/src/main/java/com/clhs/score/data/GradeAnalysis.kt

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,40 @@ data class GradeTrend(
4848
val averageLine: String = points.joinToString("") { "%.1f".format(it.weightedAverage) }
4949
}
5050

51+
data class HistoryExamRef(
52+
val yearTerm: YearTermOption,
53+
val exam: ExamOption,
54+
) {
55+
val yearValue: String = yearTerm.value
56+
val examValue: String = exam.value
57+
val examName: String = exam.text
58+
}
59+
60+
data class SimulationHistorySource(
61+
val yearTerm: YearTermOption,
62+
val exams: List<ExamOption>,
63+
val usesPreviousTerm: Boolean,
64+
val examRefs: List<HistoryExamRef>? = null,
65+
) {
66+
val historyExams: List<HistoryExamRef> = examRefs ?: exams.map { HistoryExamRef(yearTerm, it) }
67+
68+
val label: String = if (usesPreviousTerm) {
69+
"${historyExams.size} 次段考"
70+
} else {
71+
"同學期前 ${exams.size} 次考試"
72+
}
73+
}
74+
75+
data class ScoreSimulationResult(
76+
val adjustedAverage: Double,
77+
val projectedAverage: Double,
78+
val trendDelta: Double,
79+
val estimatedClassRank: Int?,
80+
val classCount: Int?,
81+
val historyCount: Int,
82+
val hasRankProjection: Boolean,
83+
)
84+
5185
data class RankProjection(
5286
val subjectName: String,
5387
val suggestedIncrease: Double,
@@ -258,6 +292,55 @@ fun YearTermOption.previousExamsOf(examValue: String?, limit: Int = 2): List<Exa
258292
return exams.subList(max(0, index - limit), index)
259293
}
260294

295+
fun List<YearTermOption>.sameTermHistorySource(
296+
yearValue: String?,
297+
examValue: String?,
298+
): SimulationHistorySource? {
299+
if (yearValue.isNullOrBlank()) return null
300+
val current = firstOrNull { it.value == yearValue } ?: return null
301+
val sameTermExams = current.previousExamsOf(examValue, limit = Int.MAX_VALUE)
302+
if (sameTermExams.isEmpty()) return null
303+
return SimulationHistorySource(
304+
yearTerm = current,
305+
exams = sameTermExams,
306+
usesPreviousTerm = false,
307+
)
308+
}
309+
310+
fun List<YearTermOption>.simulationHistorySource(
311+
yearValue: String?,
312+
examValue: String?,
313+
): SimulationHistorySource? {
314+
if (yearValue.isNullOrBlank() || examValue.isNullOrBlank()) return null
315+
val orderedExams = sortedWith(
316+
compareBy<YearTermOption>({ it.sortKey().first }, { it.sortKey().second }),
317+
).flatMap { yearTerm ->
318+
yearTerm.exams.map { exam -> HistoryExamRef(yearTerm, exam) }
319+
}
320+
val currentIndex = orderedExams.indexOfFirst {
321+
it.yearValue == yearValue && it.examValue == examValue
322+
}
323+
if (currentIndex <= 0) return null
324+
val historyExams = orderedExams.subList(max(0, currentIndex - 3), currentIndex)
325+
if (historyExams.isEmpty()) return null
326+
return SimulationHistorySource(
327+
yearTerm = historyExams.first().yearTerm,
328+
exams = historyExams.map { it.exam },
329+
usesPreviousTerm = historyExams.any { it.yearValue != yearValue },
330+
examRefs = historyExams,
331+
)
332+
}
333+
334+
fun List<YearTermOption>.latestYearTerm(): YearTermOption? =
335+
maxWithOrNull(
336+
compareBy<YearTermOption>(
337+
{ option -> parseYearTerm(option.value, defaultYear = "0", defaultTerm = "0").first.toIntOrNull() ?: 0 },
338+
{ option -> parseYearTerm(option.value, defaultYear = "0", defaultTerm = "0").second.toIntOrNull() ?: 0 },
339+
),
340+
)
341+
342+
fun YearTermOption.latestExam(): ExamOption? = exams.lastOrNull()
343+
261344
fun buildGradeTrend(
262345
currentExamName: String,
263346
currentReport: GradeReport,
@@ -269,6 +352,46 @@ fun buildGradeTrend(
269352
return GradeTrend(points = previousPoints + currentReport.toTrendPoint(currentExamName))
270353
}
271354

355+
fun simulateScores(
356+
currentReport: GradeReport,
357+
historyReports: List<GradeReport>,
358+
adjustedScores: Map<String, Double>,
359+
): ScoreSimulationResult {
360+
val adjustedAverage = weightedAverageFor(currentReport.subjects, adjustedScores)
361+
val historyPoints = historyReports + currentReport
362+
val trendDelta = historyPoints
363+
.zipWithNext { previous, next -> next.weightedAverage() - previous.weightedAverage() }
364+
.takeIf { it.isNotEmpty() }
365+
?.average()
366+
?: 0.0
367+
val projectedAverage = (adjustedAverage + trendDelta).coerceIn(0.0, 100.0)
368+
val currentRank = currentReport.examSummary?.classRank?.toInt()
369+
val classCount = currentReport.examSummary?.classCount
370+
val estimatedRank = if (!hasAdjustedScores(currentReport, adjustedScores)) {
371+
currentRank?.let { rank ->
372+
if (classCount != null && classCount > 0) rank.coerceIn(1, classCount) else rank
373+
}
374+
} else if (classCount != null && classCount > 0) {
375+
relativeRegressionRank(
376+
historyReports = historyReports,
377+
currentReport = currentReport,
378+
adjustedScores = adjustedScores,
379+
classCount = classCount,
380+
)
381+
} else {
382+
null
383+
}
384+
return ScoreSimulationResult(
385+
adjustedAverage = adjustedAverage,
386+
projectedAverage = projectedAverage,
387+
trendDelta = trendDelta,
388+
estimatedClassRank = estimatedRank,
389+
classCount = classCount,
390+
historyCount = historyReports.size,
391+
hasRankProjection = estimatedRank != null,
392+
)
393+
}
394+
272395
fun deltaText(label: String, delta: Double, unit: String = ""): String {
273396
val direction = when {
274397
delta > 0.05 -> "+"
@@ -289,6 +412,93 @@ private fun rankDelta(current: Double?, previous: Double?): Int? {
289412
return previous.toInt() - current.toInt()
290413
}
291414

415+
private fun YearTermOption.sortKey(): Pair<Int, Int> {
416+
val (year, term) = parseYearTerm(value, defaultYear = "0", defaultTerm = "0")
417+
return (year.toIntOrNull() ?: 0) to (term.toIntOrNull() ?: 0)
418+
}
419+
420+
private fun weightedAverageFor(
421+
subjects: List<SubjectScore>,
422+
adjustedScores: Map<String, Double>,
423+
): Double {
424+
val totalWeight = subjects.sumOf { subjectWeight(it.subjectName) }
425+
if (totalWeight <= 0) return 0.0
426+
val weightedTotal = subjects.sumOf { subject ->
427+
val score = adjustedScores[cleanSubjectName(subject.subjectName)] ?: subject.scoreValue
428+
score.coerceIn(0.0, 100.0) * subjectWeight(subject.subjectName)
429+
}
430+
return weightedTotal / totalWeight
431+
}
432+
433+
private fun hasAdjustedScores(
434+
report: GradeReport,
435+
adjustedScores: Map<String, Double>,
436+
): Boolean = report.subjects.any { subject ->
437+
val adjusted = adjustedScores[cleanSubjectName(subject.subjectName)] ?: subject.scoreValue
438+
abs(adjusted - subject.scoreValue) > 0.05
439+
}
440+
441+
private fun relativeRegressionRank(
442+
historyReports: List<GradeReport>,
443+
currentReport: GradeReport,
444+
adjustedScores: Map<String, Double>,
445+
classCount: Int,
446+
): Int? {
447+
val points = historyReports.mapNotNull { report ->
448+
val rank = report.examSummary?.classRank ?: return@mapNotNull null
449+
val relativeScore = report.examRelativeScore() ?: return@mapNotNull null
450+
relativeScore to rank
451+
}
452+
if (points.size < 3) return null
453+
val adjustedRelativeScore = currentReport.examRelativeScore(adjustedScores) ?: return null
454+
val relativeMean = points.map { it.first }.average()
455+
val rankMean = points.map { it.second }.average()
456+
val denominator = points.sumOf { (relativeScore, _) ->
457+
val delta = relativeScore - relativeMean
458+
delta * delta
459+
}
460+
if (denominator <= 0.0001) return null
461+
val slope = points.sumOf { (relativeScore, rank) ->
462+
(relativeScore - relativeMean) * (rank - rankMean)
463+
} / denominator
464+
val intercept = rankMean - slope * relativeMean
465+
return (slope * adjustedRelativeScore + intercept)
466+
.roundToInt()
467+
.coerceIn(1, classCount)
468+
}
469+
470+
private fun GradeReport.examRelativeScore(
471+
adjustedScores: Map<String, Double> = emptyMap(),
472+
): Double? {
473+
var weightedTotal = 0.0
474+
var totalWeight = 0
475+
subjects.forEachIndexed { index, subject ->
476+
val standard = standardFor(subject, index) ?: return@forEachIndexed
477+
val classAverage = subject.classAverageOrNull() ?: return@forEachIndexed
478+
val spread = standard.relativeSpread() ?: return@forEachIndexed
479+
val score = adjustedScores[cleanSubjectName(subject.subjectName)] ?: subject.scoreValue
480+
val weight = subjectWeight(subject.subjectName)
481+
weightedTotal += ((score.coerceIn(0.0, 100.0) - classAverage) / spread) * weight
482+
totalWeight += weight
483+
}
484+
if (totalWeight <= 0) return null
485+
return weightedTotal / totalWeight
486+
}
487+
488+
private fun SubjectScore.classAverageOrNull(): Double? =
489+
classAverageDisplay.toDoubleOrNull() ?: classAverage
490+
491+
private fun GradeStandard.relativeSpread(): Double? {
492+
val topBottom = top?.let { topValue ->
493+
bottom?.let { bottomValue -> topValue - bottomValue }
494+
}
495+
if (topBottom != null && topBottom > 0.0) return topBottom
496+
val frontBack = front?.let { frontValue ->
497+
back?.let { backValue -> frontValue - backValue }
498+
}
499+
return frontBack?.takeIf { it > 0.0 }
500+
}
501+
292502
private fun GradeReport.toTrendPoint(examName: String): GradeTrendPoint = GradeTrendPoint(
293503
examName = examName.ifBlank { examSummary?.examName.orEmpty().ifBlank { "考試" } },
294504
weightedAverage = weightedAverage(),

0 commit comments

Comments
 (0)