Skip to content

Commit 75997fe

Browse files
chore: release v1.1.3
1 parent 05bfd97 commit 75997fe

10 files changed

Lines changed: 346 additions & 11 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ shared_grades/
5151
!README.md
5252
!web/README.md
5353
!AGENTS.md
54+
!DESIGN.md
5455
!CHANGELOG.md
5556
0_build_and_deploy.txt
5657
# Exclude specific JSONs but allow config templates if any

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## [1.1.3] - 2026-05-25
4+
5+
### Features
6+
- 新增登入後的暫存資料整理,大幅降低 app 的儲存空間。
7+
8+
### Performance Improvements
9+
- 優化了成績暫存的資料,降低本機資料用量。
10+
- 減少登入頁留下的表單與網頁快取資料,降低 App Data / Cache 成長速度。
11+
312
## [1.1.2] - 2026-05-23
413

514
### Features

DESIGN.md

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
---
2+
version: alpha
3+
name: CLHS Score
4+
description: Design system for the CLHS Score web app, Android app, and demo site.
5+
colors:
6+
primary: "#1565C0"
7+
primaryDark: "#82B1FF"
8+
primaryWeb: "#2D5DAA"
9+
primaryDemo: "#4A9EFF"
10+
onPrimary: "#FFFFFF"
11+
primaryContainer: "#BBDEFB"
12+
onPrimaryContainer: "#002171"
13+
secondary: "#4F6354"
14+
secondaryDark: "#B6CCBB"
15+
secondaryContainer: "#D2E8D7"
16+
tertiary: "#33618D"
17+
success: "#126738"
18+
successDark: "#81C995"
19+
warning: "#946C00"
20+
warningDark: "#FDE293"
21+
danger: "#BA1A1A"
22+
dangerDark: "#FFB4AB"
23+
backgroundLight: "#FBFCFF"
24+
backgroundDark: "#121212"
25+
backgroundWebDark: "#111318"
26+
demoBackground: "#0A0A0F"
27+
surfaceLight: "#FBFCFF"
28+
surfaceDark: "#121212"
29+
surfaceWebDark: "#111318"
30+
surfaceElevatedLight: "#FFFFFF"
31+
surfaceElevatedDark: "#1E2025"
32+
surfaceContainerLight: "#EEF2F6"
33+
surfaceContainerDark: "#1E2225"
34+
surfaceVariantLight: "#DDE3EA"
35+
surfaceVariantDark: "#43474E"
36+
textLight: "#1A1C1E"
37+
textDark: "#E2E2E6"
38+
textWebDark: "#E1E2E8"
39+
textSecondaryLight: "#43474E"
40+
textSecondaryDark: "#C3C7CF"
41+
outlineLight: "#73777F"
42+
outlineDark: "#8D9199"
43+
outlineVariantLight: "#C3C7CF"
44+
outlineVariantDark: "#43474E"
45+
typography:
46+
display:
47+
fontFamily: Roboto
48+
fontSize: 3rem
49+
fontWeight: 700
50+
lineHeight: 1.1
51+
letterSpacing: 0
52+
title-lg:
53+
fontFamily: Roboto
54+
fontSize: 1.375rem
55+
fontWeight: 600
56+
lineHeight: 1.25
57+
letterSpacing: 0
58+
title-md:
59+
fontFamily: Roboto
60+
fontSize: 1.125rem
61+
fontWeight: 600
62+
lineHeight: 1.35
63+
letterSpacing: 0
64+
body:
65+
fontFamily: Roboto
66+
fontSize: 1rem
67+
fontWeight: 400
68+
lineHeight: 1.5
69+
letterSpacing: 0
70+
body-zh:
71+
fontFamily: Noto Sans TC
72+
fontSize: 1rem
73+
fontWeight: 400
74+
lineHeight: 1.65
75+
letterSpacing: 0
76+
label:
77+
fontFamily: Roboto
78+
fontSize: 0.8125rem
79+
fontWeight: 600
80+
lineHeight: 1.25
81+
letterSpacing: 0
82+
numeric:
83+
fontFamily: Roboto
84+
fontSize: 1rem
85+
fontWeight: 600
86+
lineHeight: 1.3
87+
letterSpacing: 0
88+
fontFeature: tnum
89+
rounded:
90+
sm: 8px
91+
md: 16px
92+
lg: 24px
93+
xl: 32px
94+
pill: 9999px
95+
spacing:
96+
xs: 4px
97+
sm: 8px
98+
md: 16px
99+
lg: 24px
100+
xl: 32px
101+
section: 64px
102+
components:
103+
button-primary:
104+
backgroundColor: "{colors.primary}"
105+
textColor: "{colors.onPrimary}"
106+
typography: "{typography.label}"
107+
rounded: "{rounded.md}"
108+
padding: 14px
109+
button-primary-hover:
110+
backgroundColor: "{colors.primaryWeb}"
111+
textColor: "{colors.onPrimary}"
112+
rounded: "{rounded.md}"
113+
card:
114+
backgroundColor: "{colors.surfaceContainerLight}"
115+
textColor: "{colors.textLight}"
116+
rounded: "{rounded.md}"
117+
padding: 16px
118+
card-dark:
119+
backgroundColor: "{colors.surfaceContainerDark}"
120+
textColor: "{colors.textDark}"
121+
rounded: "{rounded.md}"
122+
padding: 16px
123+
hero-card:
124+
backgroundColor: "{colors.primaryContainer}"
125+
textColor: "{colors.onPrimaryContainer}"
126+
rounded: "{rounded.lg}"
127+
padding: 24px
128+
chip:
129+
backgroundColor: "{colors.surfaceVariantLight}"
130+
textColor: "{colors.textSecondaryLight}"
131+
typography: "{typography.label}"
132+
rounded: "{rounded.pill}"
133+
padding: 8px
134+
chart-primary:
135+
backgroundColor: "{colors.primary}"
136+
textColor: "{colors.onPrimary}"
137+
rounded: "{rounded.sm}"
138+
size: 12px
139+
---
140+
141+
## Overview
142+
143+
壢中成績的介面是「安靜、可信、可重複使用的成績工具」,不是行銷式儀表板。設計重點是讓學生快速確認平均、排名、科目落點與趨勢,因此資訊層級、數字可讀性和狀態辨識要優先於裝飾。
144+
145+
Web 與 Android app 採 Material 3 語彙:藍色作為主要行動與資料焦點,綠色處理成長/優勢,黃橘色處理提醒,紅色只用於錯誤或低分風險。Demo site 可以更有展示感,但仍要延續深色、藍色焦點與清楚的 app icon 訊號。
146+
147+
## Colors
148+
149+
色彩以 Material 3 的 light/dark scheme 為核心。Android 的 `ScoreTheme.kt` 是 app 端主要來源;Web 的 `web/frontend/styles/tokens.css` 是網頁端主要來源。
150+
151+
- **Primary** 用於主要 CTA、選取狀態、目前資料點、進度與重點數字。
152+
- **Surface** 用於卡片、底部導覽、彈窗與表格容器;不要把大面積內容做成過亮或過飽和。
153+
- **Success / warning / danger** 只表達成績語意或系統狀態,不應當作裝飾色。
154+
- **Demo background** 可以使用接近黑色的深色背景與白色粒子,但粒子不可遮擋文字或 icon。
155+
156+
## Typography
157+
158+
Android 使用 Material 3 預設 Roboto,Web 使用 `Roboto` + `Noto Sans TC`,Demo 使用 `Outfit` + `Noto Sans TC`。新介面若未明確屬於 demo,預設使用 Roboto / Noto Sans TC。
159+
160+
數字欄位、平均、排名、百分比、分數差距必須使用 tabular numbers 或等寬數字特性,避免資料更新時水平跳動。中文說明文字使用較寬鬆 line-height,標題保持短句,不做過度行銷化文案。
161+
162+
## Layout
163+
164+
畫面應以「總覽 → 排名/平均 → 科目細節 → 圖表/趨勢 → 操作」的閱讀順序組織。手機 app 優先單欄、卡片堆疊;Web 可使用兩欄或卡片 grid,但不要讓同一層級資訊密度過高。
165+
166+
常用間距以 8px 系列建立:8px 用於小元素內距,16px 用於卡片內容,24px 用於卡片群組,32px 以上用於區塊分隔。主要內容容器在 Web 上維持可掃讀寬度,避免滿版拉長成績表格。
167+
168+
## Elevation & Depth
169+
170+
深色模式偏向低陰影、靠 surface 色階與細邊框建立層級。Light mode 可以使用柔和陰影,但卡片不應浮得太重。Demo 首屏可使用 glow、粒子與 icon shadow,但產品內頁要避免大面積裝飾光暈。
171+
172+
互動狀態以 subtle background、outline、primary tint 或 1-2px 位移呈現;不要用大幅縮放造成資料版面跳動。
173+
174+
## Shapes
175+
176+
標準卡片使用 16px 圓角,重要摘要卡可用 24px。小標籤、分布條、chip 使用 pill 或 9999px。App icon 保持圓角方形,不要把產品 UI 裡的資料卡做成過度有機或不規則形狀。
177+
178+
圖表中的 bar、legend marker、distribution segment 可用 4-12px 小圓角;表格列與資料行保持穩定高度,避免 hover 改變布局。
179+
180+
## Components
181+
182+
Primary button 用於登入、同步、下載、重新載入與主要確認動作;同一畫面只保留一個最強 primary CTA。次要動作使用 tonal、outline 或 text button。
183+
184+
成績卡應包含科目名稱、分數、班平均/標準差等必要上下文;高低分顏色要搭配文字標籤,不可只靠顏色傳達。排名與百分比要同時顯示目前值與總數或 PR 說明。
185+
186+
圖表卡要優先讓資料可讀:清楚 legend、足夠 label 空間、淡色 grid、強調我的成績與班級平均。Radar、bar、distribution chart 應共享同一套 primary/outline/semantic colors。
187+
188+
Modal 用於登入、分享、免責與確認流程。Modal 文字要短,表單錯誤用 inline feedback,避免清空使用者已輸入內容。
189+
190+
## Do's and Don'ts
191+
192+
Do:
193+
- 保持工具感:資訊密度可以高,但層級要清楚。
194+
- 使用 Material 3 color roles 和既有 tokens,不臨時創造近似藍色。
195+
- 讓平均、排名、百分比等數字對齊且容易比較。
196+
- 在 demo 或行銷頁可加入動態背景,但互動效果必須在內容層之後。
197+
198+
Don't:
199+
- 不要把學生資料、帳密、token 或正式環境 secret 寫進 UI、文件範例或截圖。
200+
- 不要用單一顏色家族鋪滿整個 app;藍色是焦點,不是背景全部。
201+
- 不要在產品內頁使用大 hero、過多裝飾卡片或阻礙掃讀的動畫。
202+
- 不要只用顏色表示成績好壞;必須保留文字或數字上下文。

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ class FakeGradeRepository : GradeRepository {
272272
forceRefresh: Boolean,
273273
): GradeReport = FakeData.reportFor(yearValue, examValue)
274274

275-
override fun logout() {
275+
override suspend fun logout() {
276276
activeSession = null
277277
}
278278

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.clhs.score.data
2+
3+
import kotlinx.serialization.Serializable
4+
import kotlinx.serialization.json.JsonObject
5+
6+
@Serializable
7+
internal data class CachedGradeReport(
8+
val cacheVersion: Int = 1,
9+
val message: String,
10+
val studentInfo: StudentInfo,
11+
val examSummary: ExamSummary?,
12+
val subjects: List<SubjectScore>,
13+
val standards: List<GradeStandard>,
14+
)
15+
16+
internal fun GradeReport.toCachedGradeReport(): CachedGradeReport = CachedGradeReport(
17+
message = message,
18+
studentInfo = studentInfo,
19+
examSummary = examSummary,
20+
subjects = subjects,
21+
standards = standards,
22+
)
23+
24+
internal fun CachedGradeReport.toGradeReport(): GradeReport = GradeReport(
25+
message = message,
26+
studentInfo = studentInfo,
27+
examSummary = examSummary,
28+
subjects = subjects,
29+
standards = standards,
30+
rawResult = JsonObject(emptyMap()),
31+
)
32+
33+
internal fun GradeReport.withoutRawResult(): GradeReport = copy(
34+
rawResult = JsonObject(emptyMap()),
35+
)

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

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class GradeCacheStore(private val context: Context) {
4040

4141
suspend fun saveGradeReport(studentNo: String, yearValue: String, examValue: String, report: GradeReport) {
4242
val key = stringPreferencesKey("grade_${studentNo}_${yearValue}_${examValue}")
43-
val serialized = json.encodeToString(report)
43+
val serialized = json.encodeToString(report.toCachedGradeReport())
4444
context.gradeDataStore.edit { prefs ->
4545
prefs[key] = serialized
4646
}
@@ -49,11 +49,31 @@ class GradeCacheStore(private val context: Context) {
4949
suspend fun loadGradeReport(studentNo: String, yearValue: String, examValue: String): GradeReport? {
5050
val key = stringPreferencesKey("grade_${studentNo}_${yearValue}_${examValue}")
5151
val serialized = context.gradeDataStore.data.map { prefs -> prefs[key] }.firstOrNull() ?: return null
52-
return try {
53-
json.decodeFromString<GradeReport>(serialized)
54-
} catch (e: Exception) {
55-
e.printStackTrace()
56-
null
52+
decodeCachedGradeReport(serialized)?.let { return it }
53+
return decodeLegacyGradeReport(serialized)?.also { migratedReport ->
54+
context.gradeDataStore.edit { prefs ->
55+
prefs[key] = json.encodeToString(migratedReport.toCachedGradeReport())
56+
}
57+
}
58+
}
59+
60+
suspend fun clearStudent(studentNo: String) {
61+
val structureKey = "structure_$studentNo"
62+
val gradePrefix = "grade_${studentNo}_"
63+
context.gradeDataStore.edit { prefs ->
64+
prefs.asMap().keys
65+
.filter { key -> key.name == structureKey || key.name.startsWith(gradePrefix) }
66+
.forEach { key -> prefs.remove(key) }
5767
}
5868
}
69+
70+
private fun decodeCachedGradeReport(serialized: String): GradeReport? =
71+
runCatching {
72+
json.decodeFromString<CachedGradeReport>(serialized).toGradeReport()
73+
}.getOrNull()
74+
75+
private fun decodeLegacyGradeReport(serialized: String): GradeReport? =
76+
runCatching {
77+
json.decodeFromString<GradeReport>(serialized).withoutRawResult()
78+
}.getOrNull()
5979
}

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ interface GradeRepository {
2121
forceRefresh: Boolean = false,
2222
): GradeReport
2323

24-
fun logout()
24+
suspend fun logout()
2525

2626
suspend fun loginWithCookies(
2727
studentNo: String,
@@ -78,9 +78,13 @@ class SchoolGradeRepository(
7878
return report
7979
}
8080

81-
override fun logout() {
81+
override suspend fun logout() {
82+
val studentNo = sessionStore.loadSession()?.studentNo
8283
sessionStore.clear()
8384
client.clearSession()
85+
if (studentNo != null) {
86+
cacheStore.clearStudent(studentNo)
87+
}
8488
}
8589

8690
override suspend fun loginWithCookies(

0 commit comments

Comments
 (0)