Skip to content

Commit a4659f8

Browse files
chore: release v1.1.5
1 parent 24c711c commit a4659f8

49 files changed

Lines changed: 1835 additions & 486 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ venv.bak/
3838
*.p12
3939

4040
# Application Data & Temporary Files
41+
.agent/skills/
4142
grades_raw.json
4243
shared_grades/
4344
.flask_session/

CHANGELOG.md

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

3+
## [1.1.5] - 2026-05-30
4+
5+
### Features
6+
- 新增「我的課表」頁面,提供方便且流暢的查詢課表體驗,並支援下載課表圖片。
7+
- 新增桌面課表小工具,可快速查看今日課程。
8+
9+
### Bug Fixes
10+
- 修正快速切換考試時,較慢的舊成績請求可能覆蓋新選擇的問題。
11+
12+
### Performance Improvements
13+
- 移除未使用的依賴,降低建置與封包負擔。
14+
- 優化成績與課表快取解析流程。
15+
316
## [1.1.4] - 2026-05-26
417

518
### Features

android/app/build.gradle.kts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,6 @@ android {
5555
targetCompatibility = JavaVersion.VERSION_17
5656
}
5757

58-
kotlin {
59-
jvmToolchain(21)
60-
compilerOptions {
61-
jvmTarget.set(JvmTarget.JVM_17)
62-
}
63-
}
64-
6558
signingConfigs {
6659
create("release") {
6760
if (hasReleaseSigning) {
@@ -95,10 +88,21 @@ android {
9588
testOptions {
9689
unitTests.isIncludeAndroidResources = true
9790
}
91+
92+
lint {
93+
disable += "ChromeOsAbiSupport"
94+
}
95+
}
96+
97+
kotlin {
98+
jvmToolchain(21)
99+
compilerOptions {
100+
jvmTarget.set(JvmTarget.JVM_17)
101+
}
98102
}
99103

100104
dependencies {
101-
val composeBom = platform("androidx.compose:compose-bom:2026.03.00")
105+
val composeBom = platform("androidx.compose:compose-bom:2026.05.01")
102106

103107
implementation(composeBom)
104108
androidTestImplementation(composeBom)
@@ -107,13 +111,14 @@ dependencies {
107111
implementation("androidx.compose.foundation:foundation")
108112

109113
implementation("androidx.compose.material:material-icons-core")
110-
implementation("androidx.compose.material:material-icons-extended")
111114
implementation("androidx.compose.material3:material3")
112115
implementation("androidx.compose.runtime:runtime")
113116
implementation("androidx.compose.ui:ui")
114117
implementation("androidx.compose.ui:ui-graphics")
115118
implementation("androidx.compose.ui:ui-tooling-preview")
116119
implementation("androidx.datastore:datastore-preferences:1.2.1")
120+
implementation("androidx.glance:glance-appwidget:1.1.1")
121+
implementation("androidx.glance:glance-material3:1.1.1")
117122
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
118123
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
119124
implementation("androidx.navigation:navigation-compose:2.9.8")

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

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,15 @@ import androidx.compose.ui.test.onNodeWithText
1919
import androidx.compose.ui.test.performClick
2020
import androidx.compose.ui.test.performScrollTo
2121
import com.clhs.score.data.ExamOption
22-
import com.clhs.score.data.ExamSummary
23-
import com.clhs.score.data.GradeReport
24-
import com.clhs.score.data.GradeStandard
2522
import com.clhs.score.data.LocalScoreInsightProvider
2623
import com.clhs.score.data.MockGradeSystem
27-
import com.clhs.score.data.StudentInfo
2824
import com.clhs.score.data.StudentScenario
29-
import com.clhs.score.data.SubjectScore
3025
import com.clhs.score.data.YearTermOption
3126
import com.clhs.score.data.buildGradeAnalysis
3227
import com.clhs.score.data.buildGradeTrend
3328
import com.clhs.score.data.cleanSubjectName
3429
import com.clhs.score.ui.theme.ScoreTheme
3530
import com.clhs.score.viewmodel.GradesUiState
36-
import kotlinx.serialization.json.JsonObject
3731
import org.junit.Rule
3832
import org.junit.Test
3933

@@ -73,7 +67,7 @@ class ScoreUiTest {
7367
composeRule.onNodeWithContentDescription("進階").assertIsDisplayed()
7468
composeRule.onAllNodesWithText("全部科目").assertCountEquals(0)
7569
composeRule.onAllNodesWithText("圖表").assertCountEquals(0)
76-
composeRule.onNodeWithText("展示學生", substring = true).assertIsDisplayed()
70+
composeRule.onNodeWithText("範例學生", substring = true).assertIsDisplayed()
7771
composeRule.onNodeWithText("加權平均").assertIsDisplayed()
7872
composeRule.onNodeWithText("班排 15/38(前 39%)").assertIsDisplayed()
7973
composeRule.onNodeWithText("類排 88/226(前 38%)").assertIsDisplayed()
@@ -185,7 +179,7 @@ class ScoreUiTest {
185179
GradesScreen(
186180
state = GradesUiState(
187181
isLoggedIn = true,
188-
studentNo = "110000",
182+
studentNo = "DEMO-000",
189183
structure = listOf(
190184
YearTermOption(
191185
text = "114學年度 上學期",
@@ -213,6 +207,7 @@ class ScoreUiTest {
213207
expanded = if (key in expanded) expanded - key else expanded + key
214208
},
215209
onOpenScoreSimulator = {},
210+
onOpenSchedule = {},
216211
)
217212
}
218213
}

android/app/src/main/AndroidManifest.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@
2222
<category android:name="android.intent.category.LAUNCHER" />
2323
</intent-filter>
2424
</activity>
25+
26+
<receiver
27+
android:name=".widget.ScheduleWidgetReceiver"
28+
android:exported="true">
29+
<intent-filter>
30+
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
31+
</intent-filter>
32+
<meta-data
33+
android:name="android.appwidget.provider"
34+
android:resource="@xml/schedule_widget_info" />
35+
</receiver>
2536
</application>
2637

2738
</manifest>

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ data class ErrorDiagnosticContext(
5050
val gradesErrorMessage: String?,
5151
)
5252

53+
data class DiagnosticEvent(
54+
val timestamp: String,
55+
val area: String,
56+
val message: String,
57+
)
58+
5359
class DeveloperDiagnostics(private val context: Context) {
5460
private val appContext = context.applicationContext
5561

@@ -107,6 +113,16 @@ class DeveloperDiagnostics(private val context: Context) {
107113
}
108114
appendLine("- 合計: ${storage.totalBytes.toReadableSize()}")
109115
appendLine()
116+
appendLine("近期診斷事件")
117+
val events = recentEvents(appContext)
118+
if (events.isEmpty()) {
119+
appendLine("- 無")
120+
} else {
121+
events.forEach { event ->
122+
appendLine("- ${event.timestamp} [${event.area}] ${event.message}")
123+
}
124+
}
125+
appendLine()
110126
appendLine("隱私: 此診斷包不包含帳密、cookie、token、學生姓名或成績內容。")
111127
}
112128
}
@@ -156,6 +172,51 @@ class DeveloperDiagnostics(private val context: Context) {
156172
.filter { it.isFile }
157173
.sumOf { file -> runCatching { file.length() }.getOrDefault(0L) }
158174
}
175+
176+
companion object {
177+
private const val DiagnosticsPrefs = "developer_diagnostics"
178+
private const val EventsKey = "recent_events"
179+
private const val MaxEvents = 20
180+
private const val Separator = "\u001F"
181+
182+
fun recordEvent(context: Context, area: String, message: String) {
183+
val appContext = context.applicationContext
184+
val nextEvent = DiagnosticEvent(
185+
timestamp = Instant.now().toString(),
186+
area = area.sanitizeEventField(),
187+
message = message.sanitizeEventField(),
188+
)
189+
val prefs = appContext.getSharedPreferences(DiagnosticsPrefs, Context.MODE_PRIVATE)
190+
val updated = (recentEvents(appContext) + nextEvent).takeLast(MaxEvents)
191+
prefs.edit()
192+
.putString(EventsKey, updated.joinToString("\n") { it.serialize() })
193+
.apply()
194+
}
195+
196+
fun recentEvents(context: Context): List<DiagnosticEvent> {
197+
val prefs = context.applicationContext.getSharedPreferences(DiagnosticsPrefs, Context.MODE_PRIVATE)
198+
return prefs.getString(EventsKey, null)
199+
?.lineSequence()
200+
?.mapNotNull { it.deserializeEvent() }
201+
?.toList()
202+
.orEmpty()
203+
}
204+
205+
private fun DiagnosticEvent.serialize(): String =
206+
listOf(timestamp, area, message).joinToString(Separator)
207+
208+
private fun String.deserializeEvent(): DiagnosticEvent? {
209+
val parts = split(Separator)
210+
if (parts.size != 3) return null
211+
return DiagnosticEvent(parts[0], parts[1], parts[2])
212+
}
213+
214+
private fun String.sanitizeEventField(): String =
215+
replace("\n", " ")
216+
.replace("\r", " ")
217+
.replace(Separator, " ")
218+
.take(240)
219+
}
159220
}
160221

161222
fun defaultClearableLocalDataCategories(): Set<LocalDataCategory> =

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

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ enum class StudentScenario {
77
}
88

99
object MockGradeSystem {
10+
private const val DemoStudentNo = "DEMO-000"
11+
private const val DemoStudentName = "範例學生"
12+
private const val DemoClassName = "示範班級"
13+
private const val DemoSeatNo = "00"
14+
1015
data class SubjectSpec(
1116
val name: String,
1217
val classAverage: Double,
@@ -120,10 +125,10 @@ object MockGradeSystem {
120125
return GradeReport(
121126
message = "fake mock data",
122127
studentInfo = StudentInfo(
123-
studentNo = "110000",
124-
studentName = "展示學生",
125-
className = "高二 3 班",
126-
seatNo = "18",
128+
studentNo = DemoStudentNo,
129+
studentName = DemoStudentName,
130+
className = DemoClassName,
131+
seatNo = DemoSeatNo,
127132
updatedAt = "2026-05-20 08:00",
128133
showClassRank = true,
129134
showClassRankCount = true,
@@ -162,7 +167,7 @@ object MockGradeSystem {
162167

163168
object FakeData {
164169
val session = AuthenticatedSession(
165-
studentNo = "demo-student",
170+
studentNo = "DEMO-000",
166171
apiToken = "fake-token",
167172
cookies = emptyMap(),
168173
)
@@ -247,22 +252,6 @@ class FakeGradeRepository : GradeRepository {
247252

248253
override fun restoreSession(): AuthenticatedSession? = activeSession
249254

250-
override suspend fun refreshCaptcha(): CaptchaChallenge = CaptchaChallenge(
251-
loginToken = "fake-login-token",
252-
shCaptchaGenCode = "fake-captcha",
253-
deviceToken = "fake-device",
254-
cookies = emptyMap(),
255-
imageBytes = ByteArray(0),
256-
contentType = "image/png",
257-
)
258-
259-
override suspend fun login(
260-
username: String,
261-
password: String,
262-
captchaCode: String,
263-
challenge: CaptchaChallenge,
264-
): AuthenticatedSession = FakeData.session.also { activeSession = it }
265-
266255
override suspend fun loadStructure(session: AuthenticatedSession, forceRefresh: Boolean): List<YearTermOption> = FakeData.structure
267256

268257
override suspend fun fetchGrades(
@@ -281,3 +270,62 @@ class FakeGradeRepository : GradeRepository {
281270
cookies: Map<String, String>,
282271
): AuthenticatedSession = FakeData.session.also { activeSession = it }
283272
}
273+
274+
object FakeScheduleData {
275+
val years = listOf(
276+
ScheduleYearTermOption("113學年度第1學期", "1131"),
277+
ScheduleYearTermOption("113學年度第2學期", "1132")
278+
)
279+
280+
val classes = listOf(
281+
ScheduleClassOption("高二 30 班", "230"),
282+
ScheduleClassOption("高二 31 班", "231"),
283+
)
284+
285+
fun report(yearValue: String, classNo: String): ScheduleReport {
286+
return ScheduleReport(
287+
yearTermValue = yearValue,
288+
items = listOf(
289+
ScheduleItem(dayOfWeek = 1, period = 1, subjectName = "國語文", teacherName = "張三", classroom = "高二30"),
290+
ScheduleItem(dayOfWeek = 1, period = 2, subjectName = "數學", teacherName = "李四", classroom = "高二30"),
291+
ScheduleItem(dayOfWeek = 1, period = 3, subjectName = "英語文", teacherName = "王五", classroom = "高二30"),
292+
ScheduleItem(dayOfWeek = 1, period = 4, subjectName = "體育", teacherName = "趙六", classroom = "操場"),
293+
ScheduleItem(dayOfWeek = 1, period = 5, subjectName = "物理", teacherName = "孫七", classroom = "高二30"),
294+
ScheduleItem(dayOfWeek = 1, period = 6, subjectName = "化學", teacherName = "周八", classroom = "實驗室"),
295+
ScheduleItem(dayOfWeek = 1, period = 7, subjectName = "歷史", teacherName = "吳九", classroom = "高二30"),
296+
297+
ScheduleItem(dayOfWeek = 2, period = 1, subjectName = "數學", teacherName = "李四", classroom = "高二30"),
298+
ScheduleItem(dayOfWeek = 2, period = 2, subjectName = "英語文", teacherName = "王五", classroom = "高二30"),
299+
ScheduleItem(dayOfWeek = 2, period = 3, subjectName = "地理", teacherName = "鄭十", classroom = "高二30"),
300+
ScheduleItem(dayOfWeek = 2, period = 4, subjectName = "國語文", teacherName = "張三", classroom = "高二30"),
301+
ScheduleItem(dayOfWeek = 2, period = 5, subjectName = "美術", teacherName = "林一", classroom = "美術教室"),
302+
ScheduleItem(dayOfWeek = 2, period = 6, subjectName = "美術", teacherName = "林一", classroom = "美術教室"),
303+
ScheduleItem(dayOfWeek = 2, period = 7, subjectName = "班會", teacherName = "導師", classroom = "高二30"),
304+
305+
ScheduleItem(dayOfWeek = 3, period = 1, subjectName = "英語文", teacherName = "王五", classroom = "高二30"),
306+
ScheduleItem(dayOfWeek = 3, period = 2, subjectName = "國語文", teacherName = "張三", classroom = "高二30"),
307+
ScheduleItem(dayOfWeek = 3, period = 3, subjectName = "數學", teacherName = "李四", classroom = "高二30"),
308+
ScheduleItem(dayOfWeek = 3, period = 4, subjectName = "公民", teacherName = "陳二", classroom = "高二30"),
309+
ScheduleItem(dayOfWeek = 3, period = 5, subjectName = "物理", teacherName = "孫七", classroom = "高二30"),
310+
ScheduleItem(dayOfWeek = 3, period = 6, subjectName = "音樂", teacherName = "黃三", classroom = "音樂教室"),
311+
ScheduleItem(dayOfWeek = 3, period = 7, subjectName = "體育", teacherName = "趙六", classroom = "體育館"),
312+
313+
ScheduleItem(dayOfWeek = 4, period = 1, subjectName = "化學", teacherName = "周八", classroom = "高二30"),
314+
ScheduleItem(dayOfWeek = 4, period = 2, subjectName = "歷史", teacherName = "吳九", classroom = "高二30"),
315+
ScheduleItem(dayOfWeek = 4, period = 3, subjectName = "國語文", teacherName = "張三", classroom = "高二30"),
316+
ScheduleItem(dayOfWeek = 4, period = 4, subjectName = "數學", teacherName = "李四", classroom = "高二30"),
317+
ScheduleItem(dayOfWeek = 4, period = 5, subjectName = "英語文", teacherName = "王五", classroom = "高二30"),
318+
ScheduleItem(dayOfWeek = 4, period = 6, subjectName = "地科", teacherName = "何四", classroom = "高二30"),
319+
ScheduleItem(dayOfWeek = 4, period = 7, subjectName = "社團", teacherName = "各社團指導", classroom = "各處"),
320+
321+
ScheduleItem(dayOfWeek = 5, period = 1, subjectName = "物理", teacherName = "孫七", classroom = "高二30"),
322+
ScheduleItem(dayOfWeek = 5, period = 2, subjectName = "化學", teacherName = "周八", classroom = "高二30"),
323+
ScheduleItem(dayOfWeek = 5, period = 3, subjectName = "英語文", teacherName = "王五", classroom = "高二30"),
324+
ScheduleItem(dayOfWeek = 5, period = 4, subjectName = "數學", teacherName = "李四", classroom = "高二30"),
325+
ScheduleItem(dayOfWeek = 5, period = 5, subjectName = "國語文", teacherName = "張三", classroom = "高二30"),
326+
ScheduleItem(dayOfWeek = 5, period = 6, subjectName = "地理", teacherName = "鄭十", classroom = "高二30"),
327+
ScheduleItem(dayOfWeek = 5, period = 7, subjectName = "公民", teacherName = "陳二", classroom = "高二30")
328+
)
329+
)
330+
}
331+
}

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

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -400,19 +400,6 @@ private fun activeWeightedSubjects(
400400
}
401401
}
402402

403-
private fun SubjectScore.classAverageOrNull(): Double? =
404-
classAverageDisplay.toDoubleOrNull() ?: classAverage
405-
406-
private fun GradeStandard.relativeSpread(): Double? {
407-
val topBottom = top?.let { topValue ->
408-
bottom?.let { bottomValue -> topValue - bottomValue }
409-
}
410-
if (topBottom != null && topBottom > 0.0) return topBottom
411-
val frontBack = front?.let { frontValue ->
412-
back?.let { backValue -> frontValue - backValue }
413-
}
414-
return frontBack?.takeIf { it > 0.0 }
415-
}
416403

417404
private fun GradeReport.toTrendPoint(examName: String): GradeTrendPoint = GradeTrendPoint(
418405
examName = examName.ifBlank { examSummary?.examName.orEmpty().ifBlank { "考試" } },

0 commit comments

Comments
 (0)