diff --git a/README.md b/README.md
index 320b601..08d640b 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,8 @@
-# RSchedule
+# TPSchedule
-电子科技大学研究生课表 APP,Flutter 实现,Android 0.4.0+7,Android 7.0 及以上。
+学生课表 APP,Flutter 实现,目前接入电子科技大学研究生系统。Android 0.5.0+8,Android 7.0 及以上。
+
+使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色,初始化完成后直接进入课表,无额外等待。Android12+ 原生启动屏使用同色纯蓝背景及图标,Flutter 接续显示蓝色渐变。
三个图标标签页:课表、考试、个人设置。界面统一蓝色,三个页面标题对齐。透明蓝色液态玻璃风格悬浮 Dock 缩窄至264,选中滑块为灰色玻璃,图标为实心蓝色,静止边距为3。支持点击图标或拖动选中滑块切换;按压和拖动时放大、松手弹簧回弹,支持的渲染模式启用实时背景折射。页面切换采用短距离移动与淡入淡出,并保留每页滚动位置。页面本身不支持左右滑动切换。课表和考试页面向下滚动时 Dock 用360ms过渡滑出屏幕底部,向上滚动恢复;个人设置页面保持显示。系统“减少动画”开启时减少切页与回弹动画。
@@ -8,13 +10,15 @@
## 安装与学校登录
-安装 `dist/RSchedule-debug.apk`,可覆盖旧版并保留本地课表与设置。内部 Dart 包名和 Android 包名保留不变以兼容升级。
+安装 `dist/TPSchedule.apk`(release运行模式),或使用 `dist/TPSchedule-debug.apk` 测试。Android包名已按用户确认改为 `com.tpschedule.app`,显示名称为TPSchedule。它与旧 `com.example.hepan_everyday` 是两个独立应用,不能覆盖安装旧RSchedule;旧应用与数据保留,新应用需要重新导入课程并设置日期。
+
+原签名密钥及签名配置不改,新旧 APK 证书及公钥会核对一致。保留 `.tools/android-user/debug.keystore`,不要删除、重建或随源码分发。当前保留的证书是Android Debug类型,release运行模式不代表更换为正式发行证书;提交商店/备案前须确认目标平台是否接受。`dist/TPSchedule-signing-info.json`、证书PEM及公钥PEM只含公开签名信息。
在个人设置中打开学校账户。应用先访问 [研究生系统专用认证入口](https://yjsjy.uestc.edu.cn/pyxx/jzsso/login),学生在学校原网页填写账号、密码和验证码;应用没有自己的密码表单,不读取或注入密码。首次认证完成后选择学校学期,确认后显示“正在查找课表,请稍候”,自动打开对应课表查询并导入保存。
学期73为2026-2027年秋、72为2025-2026年春、71为2025-2026年秋。课表路径为 `/pyxx/pygl/xskbcx/index/{学期编号}`;考试路径为 `/pyxx/pygl/kskcxx/index?xq={学期编号}`。选择成功后保存在本机,课表右上角刷新及考试更新沿用该学期。「个人设置 → 更新学期」已连接时立即选择,未连接时先认证。读取的 HTML 必须与所选学期一致,取消或保存失败不改原数据。
-成功导入不同学期会移除另一个页面的旧学期数据并清除旧开始日,避免混用;同学期刷新保留开始日。课表页在已导入但未设置开始日时,显示课程数量及“设置学期开始日”入口,直接进入日期设置。页面标题分别为课表、考试、RSchedule。
+成功导入不同学期会移除另一个页面的旧学期数据并清除旧开始日,避免混用;同学期刷新保留开始日。课表页在已导入但未设置开始日时,显示课程数量及“设置学期开始日”入口,直接进入日期设置。页面标题分别为课表、考试、TPSchedule。
专用入口的原始302响应指向 HTTP 身份认证地址,携带研究生系统的 `service` 参数。应用将该身份认证地址转到 HTTPS 并完整保留参数。学校 HTTP 回调仅允许准确的研究生系统域名。读取或保存失败保留已有数据,可在学校认证页面重试。
@@ -32,11 +36,11 @@
HTML没有学期开始日,仍须在课表设置填写实际开始日和学期周数。离线文件只有保存时那个学期的数据,预览显示实际学期;其他学期通过学校在线查询获取。JSON 支持可选 `schoolTerm: {"id": 73}` 并兼容旧课表编号推断,不允许混合学校学期。默认课前15分钟提醒,可选5/10/15/30/60分钟。启用通知和手机日历时按提示授权。JSON样例:[schedule.schema-v1.json](examples/schedule.schema-v1.json)。示例课表不覆盖本人数据,不创建通知或日历事件。
-0.4.0 修复点按滑块重复回弹及速度形变:点按平滑移动,拖动和按住时放大约30%,松手弹性回位。Dock 底座与滑块分别使用实时背景折射。兼容模式仍使用透明玻璃效果,详见 [0.4.0验证记录](docs/rschedule-0.4-validation.md)。
+0.4.0 修复点按滑块重复回弹及速度形变:点按平滑移动,拖动和按住时放大约30%,松手弹性回位。0.5.0让Dock底座和滑块呈现较通透的液态玻璃,加入实时边缘反光,降低模糊和灰色覆盖,让背景轮廓与色彩更清楚。支持的渲染方式组合折射与轻度高斯模糊,其他方式保留轻度背景模糊和玻璃亮边。详见 [0.5.0验证记录](docs/tpschedule-0.5-validation.md)。
## 开发
-源码在 `mobile/`;工具在 `.tools/`,不提交到 Git。Android 包名保留 `com.example.hepan_everyday`。
+源码在 `mobile/`;工具在 `.tools/`,不提交到 Git。Android包名 `com.tpschedule.app`。内部Dart包名 `hepan_everyday`、数据格式和原生通道标识保持不变。构建继续使用原有签名文件。
```powershell
$env:HEPAN_TOOLCHAIN_ROOT='C:/Users/david/Desktop/app/.tools'
diff --git a/docs/superpowers/plans/2026-10-13-tpschedule-brand-and-glass.md b/docs/superpowers/plans/2026-10-13-tpschedule-brand-and-glass.md
new file mode 100644
index 0000000..6bafba4
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-13-tpschedule-brand-and-glass.md
@@ -0,0 +1,11 @@
+# TPSchedule 品牌与玻璃材质修订
+
+用户已确认正式安卓包名 com.tpschedule.app。显示名称 TPSchedule,图标严格依据用户 E:/工作室logo/APP.svg,玻璃材质参考两张上传图片;保持原签名密钥。
+
+1. 保存旧 APK 签名证书摘要作为基准;不生成或替换密钥、不修改签名配置、不将私钥放入源码归档。
+2. 图标转换为原生向量及自适应图标,保留蓝色渐变、黄色日历框与白色笔画;提供匹配色彩的启动画面,兼容旧安卓及 Android12+ 系统启动屏。
+3. 更改安卓 namespace/applicationId、Kotlin包声明与启动入口;内部 Dart 包名/原生通道与数据格式保持兼容。展示字符串、手机日历标识文案及分发文件名更新 TPSchedule。
+4. 启动初始化期间显示匹配图标的 Flutter 启动画面,准备就绪后短淡出,无人为停留时长。测试异步准备期间不展示未初始化课表、完成后能进入应用。
+5. Dock 底座与滑块保留光线弯曲、色散与高光,加入明显的背景柔化模糊,减少灰色不透明覆盖。保持蓝色图标、较大按压镜片和已经修复的点按动画。
+6. 验证图标/启动颜色、玻璃真实图像像素、页面操作、全套测试及安卓构建。安装新包并查看原生启动/桌面图标/Dock;保留旧包并列安装。
+7. 对比新旧证书摘要及公钥,导出备案可用的公开签名信息,明确调试签名的现状。新包不能直接升级旧应用,旧数据需重新导入。交付安装包、源码与说明。
diff --git a/docs/tpschedule-0.5-validation.md b/docs/tpschedule-0.5-validation.md
new file mode 100644
index 0000000..1c40d77
--- /dev/null
+++ b/docs/tpschedule-0.5-validation.md
@@ -0,0 +1,50 @@
+# TPSchedule Android 0.5.0+8 验证
+
+日期:2026-10-13。用户确认显示名称TPSchedule,Android应用ID为com.tpschedule.app;原公钥、私钥与签名配置保持不变。
+
+## 品牌与启动
+
+原始图标E:/工作室logo/APP.svg复制至mobile/assets/branding/APP.svg。两者SHA256均为42263BAC3AE2C2843CA4304BACE3EB7835FF6263C08F94F232C966C0A3BBBE43。Flutter与原生向量保留蓝色渐变、黄色日历框、白色笔画和原几何数据。
+
+Android24–25使用完整向量图标,26+使用自适应图标,33+提供主题图标。自适应及原生启动前景均按安全区域缩放,原生桌面查看图标完整。
+
+旧版安卓原生启动屏采用蓝色渐变和标志,Android12+系统启动屏按平台约束使用同色纯蓝与标志;Flutter初始化期间使用原渐变背景、标志和TPSchedule文字。初始化完成即进入页面,仅淡出220ms,没有固定等待。原生录屏实际捕获Android12+系统启动屏,未为了截图增加停留时间。
+
+参考:[Android应用ID](https://developer.android.com/build/configure-app-module#application-id)、[系统启动屏](https://developer.android.com/develop/ui/views/launch/splash-screen)、[自适应图标](https://developer.android.com/develop/ui/compose/system/icon_design_adaptive)。
+
+## 玻璃材质
+
+根据用户关于雾化过强的反馈,将底座模糊从sigma8降至2,滑块从6.5降至1.6,按住时渐变至1;滑块灰色覆盖从19–26%降至8–14%,按住时约2%。底座仅保留很淡的透明着色,并收窄柔化亮边,背景轮廓与颜色更清楚。保留实时光线弯曲、色散、附近背景光的边缘反射、蓝色实心图标、点按平滑切换、按住放大约30%、拖动回弹和滚动隐藏动画。
+
+## 验证
+
+- 完整Flutter测试139项通过,使用--enable-impeller,包含真实着色器与模糊组合、移动后折射位置、背景高频细节衰减、颜色透射、动态边缘反射和Dock操作。新材质在12px条纹样例中,底座与滑块的明暗对比均高于90;旧版模糊滑块约20。细小纹理仍被轻度柔化。
+- 图标像素测试验证黄框、白色笔画、透明前景;启动背景覆盖浅深模式。GPU渐变量化存在1–2色阶差异,颜色断言允许3色阶容差。
+- 初始化测试确认完成前显示品牌页、完成后直接显示应用,无固定等待。
+- 静态分析No issues found;原生资源aapt2编译和链接通过。Debug和release APK构建成功。
+- 原始学校HTML已不在先前桌面路径,本次未重跑附件解析;使用项目内脱敏样例验证导入、学期和认证流程。0.4.0附件验证记录保留。
+- release构建需重新生成排除测试依赖的插件注册代码;仅--no-pub切换模式会保留旧注册代码。构建脚本先准备离线依赖,再让Flutter生成当前模式信息;必要的公开依赖信息刷新需联网。签名配置未修改。
+
+## 安卓实际检查
+
+API36模拟器成功安装com.tpschedule.app,与旧RSchedule并存。桌面名称TPSchedule、图标、原生启动、个人页名称以及Dock模糊/按压效果均已查看。为展示课程下方的玻璃效果,向新包手动复制了模拟器中的11条课程测试数据,不代表自动迁移功能,也不预置在分发包中。随后release包成功覆盖同包debug版并保留该测试数据。
+
+实际截图及录屏位于dist/screenshots:tpschedule-logo.png(组件渲染)、tpschedule-launch.png、tpschedule-launch.mp4、tpschedule-launcher.png、tpschedule-week.png、tpschedule-profile.png、tpschedule-dock-held.png。
+
+本机安卓模拟器GPU驱动仍有限制,原生检查使用启动参数开启软件渲染并关闭Impeller;截图展示模糊玻璃兼容效果。交付APK保留手机默认渲染方式。实时折射、模糊组合与背景反射已在支持该路径的Flutter测试渲染器中进行实际像素验证,仍需用户手机验证具体视觉与性能。
+
+## 包名与签名
+
+release APK:com.tpschedule.app,versionName0.5.0/versionCode8,minSdk24/targetSdk36,约55.1MiB,无debuggable标记。旧包com.example.hepan_everyday不能被新包覆盖,需重新导入课程和设置日期;旧包和数据保留。
+
+apksigner验证通过。新debug、release与旧0.4.0 APK签名证书DER完全一致,因此证书及公钥未变:
+
+- 证书SHA256:AF5C21B1BA1E48E461D8EA38DF0FE1C3653F64EB7E8E7728BAE6E5D988BA0D0A。
+- 证书SHA1:DFC7E56F60C78CAC14E38519E89F46C615D456D7。
+- 证书MD5:78DE9119AD14727FA1C5E5E4E68145EE。
+
+dist/TPSchedule-signing-info.json、TPSchedule-signing-certificate.pem、TPSchedule-public-key.pem及TPSchedule-备案信息.txt只包含公开信息,未导出私钥。原签名文件.tools/android-user/debug.keystore保持原位置,不随源码归档分发。
+
+当前保留证书Subject为CN=Android Debug,O=Android,C=US。release运行模式不代表更换为正式发行证书;本次遵照要求不换密钥,备案或商店是否接受须按实际平台核实。尚未提交备案或应用商店审核。
+
+release APK SHA256:3EAD36A3270C45A0EBB50B33056BCE4244C8DC0024F23720D794BB272328E904。
diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore
index be3943c..82677b8 100644
--- a/mobile/android/.gitignore
+++ b/mobile/android/.gitignore
@@ -6,6 +6,7 @@ gradle-wrapper.jar
/local.properties
GeneratedPluginRegistrant.java
.cxx/
+.kotlin/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts
index 2b7e561..61bc2b1 100644
--- a/mobile/android/app/build.gradle.kts
+++ b/mobile/android/app/build.gradle.kts
@@ -5,7 +5,7 @@ plugins {
}
android {
- namespace = "com.example.hepan_everyday"
+ namespace = "com.tpschedule.app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@@ -16,8 +16,7 @@ android {
}
defaultConfig {
- // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
- applicationId = "com.example.hepan_everyday"
+ applicationId = "com.tpschedule.app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 24
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index b0b3708..0a0d147 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -6,10 +6,11 @@
+ android:icon="@mipmap/ic_launcher"
+ android:roundIcon="@mipmap/ic_launcher">
diff --git a/mobile/android/app/src/main/kotlin/com/example/hepan_everyday/CalendarBridge.kt b/mobile/android/app/src/main/kotlin/com/tpschedule/app/CalendarBridge.kt
similarity index 99%
rename from mobile/android/app/src/main/kotlin/com/example/hepan_everyday/CalendarBridge.kt
rename to mobile/android/app/src/main/kotlin/com/tpschedule/app/CalendarBridge.kt
index aa424df..f29a026 100644
--- a/mobile/android/app/src/main/kotlin/com/example/hepan_everyday/CalendarBridge.kt
+++ b/mobile/android/app/src/main/kotlin/com/tpschedule/app/CalendarBridge.kt
@@ -1,4 +1,4 @@
-package com.example.hepan_everyday
+package com.tpschedule.app
import android.Manifest
import android.app.Activity
diff --git a/mobile/android/app/src/main/kotlin/com/example/hepan_everyday/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/tpschedule/app/MainActivity.kt
similarity index 95%
rename from mobile/android/app/src/main/kotlin/com/example/hepan_everyday/MainActivity.kt
rename to mobile/android/app/src/main/kotlin/com/tpschedule/app/MainActivity.kt
index ce6993d..6a76a2f 100644
--- a/mobile/android/app/src/main/kotlin/com/example/hepan_everyday/MainActivity.kt
+++ b/mobile/android/app/src/main/kotlin/com/tpschedule/app/MainActivity.kt
@@ -1,4 +1,4 @@
-package com.example.hepan_everyday
+package com.tpschedule.app
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
diff --git a/mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml
index f74085f..6bd4fdb 100644
--- a/mobile/android/app/src/main/res/drawable-v21/launch_background.xml
+++ b/mobile/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -1,12 +1,5 @@
-
-
-
-
-
+
+
diff --git a/mobile/android/app/src/main/res/drawable/ic_tpschedule_foreground.xml b/mobile/android/app/src/main/res/drawable/ic_tpschedule_foreground.xml
new file mode 100644
index 0000000..1a2ada6
--- /dev/null
+++ b/mobile/android/app/src/main/res/drawable/ic_tpschedule_foreground.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/res/drawable/ic_tpschedule_launcher.xml b/mobile/android/app/src/main/res/drawable/ic_tpschedule_launcher.xml
new file mode 100644
index 0000000..7128271
--- /dev/null
+++ b/mobile/android/app/src/main/res/drawable/ic_tpschedule_launcher.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/res/drawable/ic_tpschedule_logo.xml b/mobile/android/app/src/main/res/drawable/ic_tpschedule_logo.xml
new file mode 100644
index 0000000..9609bc3
--- /dev/null
+++ b/mobile/android/app/src/main/res/drawable/ic_tpschedule_logo.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/res/drawable/ic_tpschedule_monochrome.xml b/mobile/android/app/src/main/res/drawable/ic_tpschedule_monochrome.xml
new file mode 100644
index 0000000..8c1463c
--- /dev/null
+++ b/mobile/android/app/src/main/res/drawable/ic_tpschedule_monochrome.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/res/drawable/ic_tpschedule_splash.xml b/mobile/android/app/src/main/res/drawable/ic_tpschedule_splash.xml
new file mode 100644
index 0000000..6e96206
--- /dev/null
+++ b/mobile/android/app/src/main/res/drawable/ic_tpschedule_splash.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/res/drawable/launch_background.xml b/mobile/android/app/src/main/res/drawable/launch_background.xml
index 304732f..6bd4fdb 100644
--- a/mobile/android/app/src/main/res/drawable/launch_background.xml
+++ b/mobile/android/app/src/main/res/drawable/launch_background.xml
@@ -1,12 +1,5 @@
-
-
-
-
-
+
+
diff --git a/mobile/android/app/src/main/res/drawable/tpschedule_gradient.xml b/mobile/android/app/src/main/res/drawable/tpschedule_gradient.xml
new file mode 100644
index 0000000..5b7e50d
--- /dev/null
+++ b/mobile/android/app/src/main/res/drawable/tpschedule_gradient.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..dbc1389
--- /dev/null
+++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml
new file mode 100644
index 0000000..190ac76
--- /dev/null
+++ b/mobile/android/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/mobile/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml
new file mode 100644
index 0000000..7128271
--- /dev/null
+++ b/mobile/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/android/app/src/main/res/values-night-v31/styles.xml b/mobile/android/app/src/main/res/values-night-v31/styles.xml
new file mode 100644
index 0000000..f65ba6c
--- /dev/null
+++ b/mobile/android/app/src/main/res/values-night-v31/styles.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/mobile/android/app/src/main/res/values-night/styles.xml b/mobile/android/app/src/main/res/values-night/styles.xml
index 06952be..745eea2 100644
--- a/mobile/android/app/src/main/res/values-night/styles.xml
+++ b/mobile/android/app/src/main/res/values-night/styles.xml
@@ -1,18 +1,17 @@
-
-
-
-
diff --git a/mobile/android/app/src/main/res/values-v31/styles.xml b/mobile/android/app/src/main/res/values-v31/styles.xml
new file mode 100644
index 0000000..f65ba6c
--- /dev/null
+++ b/mobile/android/app/src/main/res/values-v31/styles.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml
index cb1ef88..745eea2 100644
--- a/mobile/android/app/src/main/res/values/styles.xml
+++ b/mobile/android/app/src/main/res/values/styles.xml
@@ -1,18 +1,17 @@
-
-
diff --git a/mobile/android/app/src/main/res/values/tpschedule_colors.xml b/mobile/android/app/src/main/res/values/tpschedule_colors.xml
new file mode 100644
index 0000000..8d838ed
--- /dev/null
+++ b/mobile/android/app/src/main/res/values/tpschedule_colors.xml
@@ -0,0 +1,4 @@
+
+
+ #38B3FF
+
diff --git a/mobile/assets/branding/APP.svg b/mobile/assets/branding/APP.svg
new file mode 100644
index 0000000..f5fc7b6
--- /dev/null
+++ b/mobile/assets/branding/APP.svg
@@ -0,0 +1,46 @@
+
+
\ No newline at end of file
diff --git a/mobile/lib/app/hepan_app.dart b/mobile/lib/app/hepan_app.dart
index c61dd1e..caa2922 100644
--- a/mobile/lib/app/hepan_app.dart
+++ b/mobile/lib/app/hepan_app.dart
@@ -28,7 +28,7 @@ class HepanApp extends StatelessWidget {
@override
Widget build(BuildContext context) => MaterialApp(
debugShowCheckedModeBanner: false,
- title: 'RSchedule',
+ title: 'TPSchedule',
theme: hepanTheme(),
locale: const Locale('zh', 'CN'),
supportedLocales: const [Locale('zh', 'CN')],
diff --git a/mobile/lib/app/tp_launch_page.dart b/mobile/lib/app/tp_launch_page.dart
new file mode 100644
index 0000000..a06c236
--- /dev/null
+++ b/mobile/lib/app/tp_launch_page.dart
@@ -0,0 +1,45 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+import '../widgets/tp_logo.dart';
+
+/// The first Flutter frame while saved data and reminder services initialize.
+class TpLaunchPage extends StatelessWidget {
+ const TpLaunchPage({super.key});
+
+ @override
+ Widget build(BuildContext context) => AnnotatedRegion(
+ value: const SystemUiOverlayStyle(
+ statusBarColor: Colors.transparent,
+ statusBarIconBrightness: Brightness.light,
+ statusBarBrightness: Brightness.dark,
+ systemNavigationBarColor: Color(0xFF38B3FF),
+ systemNavigationBarIconBrightness: Brightness.light,
+ ),
+ child: Scaffold(
+ backgroundColor: const Color(0xFF38B3FF),
+ body: DecoratedBox(
+ decoration: const BoxDecoration(gradient: tpBrandGradient),
+ child: SizedBox.expand(
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const TpLogo(size: 120, background: false),
+ const SizedBox(height: 20),
+ Text(
+ 'TPSchedule',
+ style: Theme.of(context).textTheme.headlineSmall?.copyWith(
+ color: Colors.white,
+ fontWeight: FontWeight.w600,
+ letterSpacing: 0.4,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+}
diff --git a/mobile/lib/app/tp_startup.dart b/mobile/lib/app/tp_startup.dart
new file mode 100644
index 0000000..3f62f27
--- /dev/null
+++ b/mobile/lib/app/tp_startup.dart
@@ -0,0 +1,48 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+import 'tp_launch_page.dart';
+
+/// Displays the brand during real initialization, without a minimum wait.
+class TpStartup extends StatelessWidget {
+ const TpStartup({super.key, required this.ready, required this.child});
+ final Future ready;
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) => FutureBuilder(
+ future: ready,
+ builder: (context, snapshot) {
+ final complete = snapshot.connectionState == ConnectionState.done;
+ final reduceMotion =
+ MediaQuery.maybeOf(context)?.disableAnimations ??
+ WidgetsBinding
+ .instance
+ .platformDispatcher
+ .accessibilityFeatures
+ .disableAnimations;
+ return AnimatedSwitcher(
+ duration: reduceMotion
+ ? Duration.zero
+ : const Duration(milliseconds: 220),
+ switchInCurve: Curves.easeOut,
+ switchOutCurve: Curves.easeIn,
+ child: complete
+ ? AnnotatedRegion(
+ key: const ValueKey('tp-ready'),
+ value: SystemUiOverlayStyle.dark.copyWith(
+ statusBarColor: Colors.transparent,
+ systemNavigationBarColor: const Color(0xFFF3F7FF),
+ systemNavigationBarIconBrightness: Brightness.dark,
+ ),
+ child: child,
+ )
+ : const MaterialApp(
+ key: ValueKey('tp-launch'),
+ debugShowCheckedModeBanner: false,
+ home: TpLaunchPage(),
+ ),
+ );
+ },
+ );
+}
diff --git a/mobile/lib/features/profile/profile_page.dart b/mobile/lib/features/profile/profile_page.dart
index a3b7263..ccb902e 100644
--- a/mobile/lib/features/profile/profile_page.dart
+++ b/mobile/lib/features/profile/profile_page.dart
@@ -70,7 +70,7 @@ class ProfilePage extends StatelessWidget {
),
children: [
const Text(
- 'RSchedule',
+ 'TPSchedule',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const SizedBox(height: 16),
@@ -220,7 +220,7 @@ class ProfilePage extends StatelessWidget {
child: Column(
children: [
const Text(
- 'RSchedule',
+ 'TPSchedule',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
diff --git a/mobile/lib/features/profile/settings_page.dart b/mobile/lib/features/profile/settings_page.dart
index ee5baeb..e618335 100644
--- a/mobile/lib/features/profile/settings_page.dart
+++ b/mobile/lib/features/profile/settings_page.dart
@@ -94,7 +94,7 @@ class _SettingsPageState extends State {
context: context,
builder: (context) => AlertDialog(
title: const Text('关闭日历同步'),
- content: const Text('是否删除Riverside Schedule 创建的课程日程?其他日程不会受影响。'),
+ content: const Text('是否删除 TPSchedule 创建的课程日程?其他日程不会受影响。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart
index 93b2df7..325a87c 100644
--- a/mobile/lib/main.dart
+++ b/mobile/lib/main.dart
@@ -4,6 +4,7 @@ import 'package:timezone/data/latest.dart' as tzdata;
import 'app/app_controller.dart';
import 'app/hepan_app.dart';
+import 'app/tp_startup.dart';
import 'data/file_snapshot_store.dart';
import 'services/local_notification_service.dart';
import 'services/android_calendar_service.dart';
@@ -33,12 +34,14 @@ Future main() async {
AndroidCalendarService(MethodCalendarGateway()),
DateTime.now,
);
- await controller.initialize();
runApp(
- buildApplication(
- controller,
- notificationSelection: selection,
- school: SchoolSession(),
+ TpStartup(
+ ready: controller.initialize(),
+ child: buildApplication(
+ controller,
+ notificationSelection: selection,
+ school: SchoolSession(),
+ ),
),
);
}
diff --git a/mobile/lib/services/android_calendar_service.dart b/mobile/lib/services/android_calendar_service.dart
index ed0b0d3..ba7e53e 100644
--- a/mobile/lib/services/android_calendar_service.dart
+++ b/mobile/lib/services/android_calendar_service.dart
@@ -87,7 +87,7 @@ class AndroidCalendarService extends CalendarService {
calendarId: calendarId,
title: occurrence.course.name,
room: occurrence.course.room,
- description: '任课老师:${occurrence.course.teacher}\nRiverside Schedule',
+ description: '任课老师:${occurrence.course.teacher}\nTPSchedule',
startMs: occurrence.start.millisecondsSinceEpoch,
endMs: occurrence.end.millisecondsSinceEpoch,
),
diff --git a/mobile/lib/widgets/glass_dock.dart b/mobile/lib/widgets/glass_dock.dart
index 366259b..0008932 100644
--- a/mobile/lib/widgets/glass_dock.dart
+++ b/mobile/lib/widgets/glass_dock.dart
@@ -126,11 +126,11 @@ class _GlassDockState extends State with TickerProviderStateMixin {
colors: lens
? [
const Color(0xFFD8DBE0)
- .withValues(alpha: 0.72 * (1 - pressure * 0.91)),
+ .withValues(alpha: 0.14 * (1 - pressure * 0.82)),
const Color(0xFFC3C7CE)
- .withValues(alpha: 0.60 * (1 - pressure * 0.93)),
+ .withValues(alpha: 0.08 * (1 - pressure * 0.82)),
const Color(0xFFE5E7EB)
- .withValues(alpha: 0.72 * (1 - pressure * 0.90)),
+ .withValues(alpha: 0.12 * (1 - pressure * 0.82)),
]
: [
Colors.white.withValues(alpha: 0.028),
@@ -145,7 +145,8 @@ class _GlassDockState extends State with TickerProviderStateMixin {
return ClipRRect(
borderRadius: BorderRadius.circular(40),
child: GlassRefraction(
- strength: lens ? 0.25 + pressure * 0.95 : 0.32,
+ strength: lens ? 0.70 + pressure * 0.70 : 0.55,
+ blurSigma: lens ? 1.6 - pressure * 0.6 : 2,
child: content,
),
);
@@ -177,8 +178,8 @@ class _GlassDockState extends State with TickerProviderStateMixin {
borderRadius: BorderRadius.circular(40),
boxShadow: [
BoxShadow(
- color: _blue.withValues(alpha: 0.035),
- blurRadius: 12,
+ color: const Color(0xFF4C6078).withValues(alpha: 0.075),
+ blurRadius: 16,
offset: const Offset(0, 5),
),
],
@@ -288,15 +289,14 @@ class _GlassDockState extends State with TickerProviderStateMixin {
);
}
-/// Thin paired rims and directional glints suggest rounded optical thickness.
-/// All strong highlights stay on the edge; the centre remains clear.
+/// A diffuse silver bevel and thin directional glints show optical thickness.
+/// The painted highlights stay on the edge so background colour passes through.
class _GlassRimPainter extends CustomPainter {
const _GlassRimPainter({required this.lens});
final bool lens;
- static const _blue = Color(0xFF007AFF);
@override
void paint(Canvas canvas, Size size) {
- final edge = lens ? const Color(0xFF7B838F) : _blue;
+ final edge = lens ? const Color(0xFF7B838F) : const Color(0xFFAFBECF);
final bounds = Offset.zero & size;
final outer = RRect.fromRectAndRadius(
bounds.deflate(0.7),
@@ -306,6 +306,23 @@ class _GlassRimPainter extends CustomPainter {
bounds.deflate(2.2),
Radius.circular(size.height / 2 - 1.5),
);
+ canvas.drawRRect(
+ outer,
+ Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = lens ? 1.8 : 2.2
+ ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 0.9)
+ ..shader = ui.Gradient.linear(
+ bounds.topLeft,
+ bounds.bottomRight,
+ [
+ Colors.white.withValues(alpha: 0.24),
+ edge.withValues(alpha: 0.04),
+ Colors.white.withValues(alpha: 0.14),
+ ],
+ [0.0, 0.55, 1.0],
+ ),
+ );
canvas.drawRRect(
outer,
Paint()
@@ -336,9 +353,9 @@ class _GlassRimPainter extends CustomPainter {
bounds.topLeft,
bounds.bottomRight,
[
- Colors.white.withValues(alpha: 0.30),
+ Colors.white.withValues(alpha: 0.20),
edge.withValues(alpha: lens ? 0.13 : 0.07),
- Colors.white.withValues(alpha: 0.40),
+ Colors.white.withValues(alpha: 0.28),
],
[0.0, 0.5, 1.0],
),
@@ -362,7 +379,7 @@ class _GlassRimPainter extends CustomPainter {
Offset(size.width, 0),
[
Colors.white.withValues(alpha: 0),
- Colors.white.withValues(alpha: 0.65),
+ Colors.white.withValues(alpha: 0.50),
Colors.white.withValues(alpha: 0),
],
[0.0, 0.5, 1.0],
diff --git a/mobile/lib/widgets/glass_refraction.dart b/mobile/lib/widgets/glass_refraction.dart
index 0070c32..fe6da8a 100644
--- a/mobile/lib/widgets/glass_refraction.dart
+++ b/mobile/lib/widgets/glass_refraction.dart
@@ -8,9 +8,13 @@ class GlassRefraction extends StatefulWidget {
const GlassRefraction({
super.key,
required this.strength,
+ this.blurSigma = 2,
required this.child,
});
final double strength;
+
+ /// Optical softness in logical pixels, independent of display density.
+ final double blurSigma;
final Widget child;
@override
State createState() => _GlassRefractionState();
@@ -18,6 +22,7 @@ class GlassRefraction extends StatefulWidget {
class _GlassRefractionState extends State {
static Future? _program;
+ static ui.FragmentProgram? _loadedProgram;
ui.FragmentShader? _shader;
@override
void initState() {
@@ -27,12 +32,12 @@ class _GlassRefractionState extends State {
Future _load() async {
try {
- final program = await (_program ??= ui.FragmentProgram.fromAsset(
- 'shaders/liquid_glass.frag',
- ));
+ final program = _loadedProgram ??= await (_program ??=
+ ui.FragmentProgram.fromAsset('shaders/liquid_glass.frag'));
if (mounted) setState(() => _shader = program.fragmentShader());
} catch (error) {
_program = null;
+ _loadedProgram = null;
debugPrint('Glass refraction unavailable: $error');
}
}
@@ -46,12 +51,17 @@ class _GlassRefractionState extends State {
@override
Widget build(BuildContext context) => _shader == null
? BackdropFilter(
- filter: ui.ImageFilter.blur(sigmaX: 0.6, sigmaY: 0.6),
+ filter: ui.ImageFilter.blur(
+ sigmaX: widget.blurSigma,
+ sigmaY: widget.blurSigma,
+ tileMode: TileMode.clamp,
+ ),
child: widget.child,
)
: _LiveGlassFilter(
shader: _shader!,
strength: widget.strength,
+ blurSigma: widget.blurSigma,
pixelRatio: MediaQuery.devicePixelRatioOf(context),
child: widget.child,
);
@@ -61,29 +71,41 @@ class _LiveGlassFilter extends SingleChildRenderObjectWidget {
const _LiveGlassFilter({
required this.shader,
required this.strength,
+ required this.blurSigma,
required this.pixelRatio,
required super.child,
});
final ui.FragmentShader shader;
- final double strength, pixelRatio;
+ final double strength, blurSigma, pixelRatio;
@override
_RenderLiveGlass createRenderObject(BuildContext context) =>
- _RenderLiveGlass(shader, strength, pixelRatio);
+ _RenderLiveGlass(shader, strength, blurSigma, pixelRatio);
@override
void updateRenderObject(
BuildContext context,
_RenderLiveGlass renderObject,
- ) => renderObject.update(shader, strength, pixelRatio);
+ ) => renderObject.update(shader, strength, blurSigma, pixelRatio);
}
class _RenderLiveGlass extends RenderProxyBox {
- _RenderLiveGlass(this._shader, this._strength, this._pixelRatio);
+ _RenderLiveGlass(
+ this._shader,
+ this._strength,
+ this._blurSigma,
+ this._pixelRatio,
+ );
ui.FragmentShader _shader;
- double _strength, _pixelRatio;
+ double _strength, _blurSigma, _pixelRatio;
final _layer = LayerHandle();
- void update(ui.FragmentShader shader, double strength, double pixelRatio) {
+ void update(
+ ui.FragmentShader shader,
+ double strength,
+ double blurSigma,
+ double pixelRatio,
+ ) {
_shader = shader;
_strength = strength;
+ _blurSigma = blurSigma;
_pixelRatio = pixelRatio;
markNeedsPaint();
}
@@ -103,7 +125,17 @@ class _RenderLiveGlass extends RenderProxyBox {
..setFloat(5, size.height * _pixelRatio)
..setFloat(6, _strength);
_layer.layer ??= BackdropFilterLayer();
- _layer.layer!.filter = ui.ImageFilter.shader(_shader);
+ // Refract the original backdrop first, then diffuse it with the engine's
+ // separable Gaussian blur. Keeping the shader inside the composition also
+ // keeps its source texture coordinates independent of blur padding.
+ _layer.layer!.filter = ui.ImageFilter.compose(
+ outer: ui.ImageFilter.blur(
+ sigmaX: _blurSigma,
+ sigmaY: _blurSigma,
+ tileMode: TileMode.clamp,
+ ),
+ inner: ui.ImageFilter.shader(_shader),
+ );
context.pushLayer(_layer.layer!, super.paint, offset);
}
diff --git a/mobile/lib/widgets/tp_logo.dart b/mobile/lib/widgets/tp_logo.dart
new file mode 100644
index 0000000..4a6d7c7
--- /dev/null
+++ b/mobile/lib/widgets/tp_logo.dart
@@ -0,0 +1,93 @@
+import 'package:flutter/material.dart';
+
+/// The original APP.svg gradient, expressed in its view-box coordinates.
+const tpBrandGradient = LinearGradient(
+ begin: Alignment(2 * 324.09 / 2008.27 - 1, 2 * 1572.05 / 2009.62 - 1),
+ end: Alignment(2 * 1871.99 / 2008.27 - 1, 2 * 280.93 / 2009.62 - 1),
+ colors: [Color(0xFF3FD1FF), Color(0xFF38B3FF), Color(0xFF2A6CFF)],
+ stops: [0, 0.28, 1],
+);
+
+/// Vector rendering of assets/branding/APP.svg; no raster scaling or redesign.
+class TpLogo extends StatelessWidget {
+ const TpLogo({super.key, this.size = 128, this.background = true});
+
+ final double size;
+ final bool background;
+
+ @override
+ Widget build(BuildContext context) => Semantics(
+ label: 'TPSchedule 标志',
+ image: true,
+ child: SizedBox.square(
+ dimension: size,
+ child: CustomPaint(painter: _TpLogoPainter(background: background)),
+ ),
+ );
+}
+
+class _TpLogoPainter extends CustomPainter {
+ const _TpLogoPainter({required this.background});
+ final bool background;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ canvas.save();
+ canvas.scale(size.width / 2008.27, size.height / 2009.62);
+ if (background) {
+ const bounds = Rect.fromLTWH(0.5, 0.5, 2007.27, 2008.62);
+ canvas.drawRect(
+ bounds,
+ Paint()
+ ..shader = tpBrandGradient.createShader(
+ const Rect.fromLTWH(0, 0, 2008.27, 2009.62),
+ ),
+ );
+ canvas.drawRect(
+ bounds,
+ Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = 1
+ ..color = Colors.black,
+ );
+ }
+ final stroke = Paint()
+ ..style = PaintingStyle.stroke
+ ..strokeJoin = StrokeJoin.miter
+ ..strokeMiterLimit = 10
+ ..strokeCap = StrokeCap.butt;
+ canvas.drawRRect(
+ RRect.fromRectAndRadius(
+ const Rect.fromLTWH(405.1, 514.64, 1198.08, 1071.89),
+ const Radius.circular(20),
+ ),
+ stroke
+ ..color = const Color(0xFFFFD22A)
+ ..strokeWidth = 217,
+ );
+ stroke
+ ..color = Colors.white
+ ..strokeWidth = 150;
+ for (final x in [704.07, 1249.91]) {
+ canvas.drawRRect(
+ RRect.fromRectAndRadius(
+ Rect.fromLTWH(x, 339.92, 64.51, 349.44),
+ const Radius.circular(27.65),
+ ),
+ stroke,
+ );
+ }
+ canvas.drawPath(
+ Path()
+ ..moveTo(729.01, 1004.81)
+ ..relativeCubicTo(70.21, 112.08, 200.6, 174.7, 327.98, 152.69)
+ ..relativeCubicTo(122.48, -18.51, 206.2, -123.19, 222.28, -152.69),
+ stroke..strokeWidth = 185,
+ );
+ canvas.restore();
+ }
+
+ @override
+ bool shouldRepaint(_TpLogoPainter oldDelegate) =>
+ background != oldDelegate.background;
+}
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 10a4034..a01fccf 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -1,7 +1,7 @@
name: hepan_everyday
-description: RSchedule 研究生课表
+description: TPSchedule 学生课表
publish_to: none
-version: 0.4.0+7
+version: 0.5.0+8
environment:
sdk: ^3.13.5
dependencies:
@@ -24,5 +24,7 @@ dev_dependencies:
flutter_lints: ^6.0.0
flutter:
uses-material-design: true
+ assets:
+ - assets/branding/
shaders:
- shaders/liquid_glass.frag
diff --git a/mobile/shaders/liquid_glass.frag b/mobile/shaders/liquid_glass.frag
index 7f6ebf4..2a460cb 100644
--- a/mobile/shaders/liquid_glass.frag
+++ b/mobile/shaders/liquid_glass.frag
@@ -34,8 +34,14 @@ void main() {
float dispersion = u_strength * rim * 0.65;
colour.r = texture(u_background, sampleUV(ray + normal * dispersion)).r;
colour.b = texture(u_background, sampleUV(ray - normal * dispersion)).b;
- float reflection = pow(rim, 4.0) * u_strength * 0.10
- * max(0.0, dot(normal, normalize(vec2(-0.5, -0.8))));
+ // The curved rim catches nearby background light as well as transmitting it.
+ // Reflection follows live pixels instead of being a painted white outline.
+ float facing = max(0.0, dot(normal, normalize(vec2(-0.5, -0.8))));
+ float edge_light = pow(rim, 4.0) * u_strength * facing;
+ vec4 reflected = texture(u_background,
+ sampleUV(point + normal * radius * 0.40));
+ colour = mix(colour, reflected, min(edge_light * 0.18, 0.22));
+ float reflection = edge_light * 0.07;
colour.rgb = min(colour.rgb + reflection * colour.a, vec3(colour.a));
frag_color = colour;
}
diff --git a/mobile/test/widgets/dock_motion_test.dart b/mobile/test/widgets/dock_motion_test.dart
index 33ac9a9..3dd6459 100644
--- a/mobile/test/widgets/dock_motion_test.dart
+++ b/mobile/test/widgets/dock_motion_test.dart
@@ -86,12 +86,7 @@ void main() {
),
);
await tester.pumpAndSettle();
- final lens = find
- .descendant(
- of: find.byType(GlassDock),
- matching: find.byType(BackdropFilter),
- )
- .last;
+ final lens = find.byType(GlassRefraction).last;
final resting = tester.getSize(lens);
final gesture = await tester.startGesture(
tester.getCenter(find.byKey(const Key('dock-cursor'))),
diff --git a/mobile/test/widgets/glass_material_test.dart b/mobile/test/widgets/glass_material_test.dart
new file mode 100644
index 0000000..27addb2
--- /dev/null
+++ b/mobile/test/widgets/glass_material_test.dart
@@ -0,0 +1,270 @@
+import 'dart:io';
+import 'dart:typed_data';
+import 'dart:ui' as ui;
+
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:hepan_everyday/widgets/glass_dock.dart';
+import 'package:hepan_everyday/widgets/glass_refraction.dart';
+
+void main() {
+ testWidgets(
+ 'glass softens fine background detail without whitening its colour',
+ (tester) async {
+ final pixels = await _render(
+ tester,
+ const GlassRefraction(strength: 0.32, child: SizedBox.expand()),
+ stripes: true,
+ );
+ final reds = [for (var x = 110; x < 150; x++) _channel(pixels, x, 34, 0)];
+ final contrast =
+ reds.reduce((a, b) => a > b ? a : b) -
+ reds.reduce((a, b) => a < b ? a : b);
+ expect(contrast, lessThan(25));
+ expect(_channel(pixels, 132, 34, 0), closeTo(130, 15));
+ expect(_channel(pixels, 132, 34, 1), closeTo(120, 15));
+ expect(_channel(pixels, 132, 34, 2), closeTo(140, 15));
+ expect(_channel(pixels, 132, 34, 3), 255);
+ },
+ );
+
+ testWidgets(
+ 'Dock lens preserves the backdrop colour through its neutral tint',
+ (tester) async {
+ final pixels = await _render(
+ tester,
+ GlassDock(selectedIndex: 0, onSelected: (_) {}),
+ );
+ // Below the symbol and away from the rim, the selected lens should retain
+ // most of this saturated cyan backdrop instead of painting a grey pill.
+ final red = _channel(pixels, 46, 52, 0);
+ final green = _channel(pixels, 46, 52, 1);
+ final blue = _channel(pixels, 46, 52, 2);
+ expect(red, lessThan(50));
+ expect(green - red, greaterThan(130));
+ expect(blue - red, greaterThan(165));
+ },
+ );
+
+ testWidgets('Dock base and selected lens both soften the live backdrop', (
+ tester,
+ ) async {
+ final pixels = await _render(
+ tester,
+ GlassDock(selectedIndex: 0, onSelected: (_) {}),
+ stripes: true,
+ preview: true,
+ );
+ for (final start in [32, 160]) {
+ final reds = [
+ for (var x = start; x < start + 20; x++) _channel(pixels, x, 52, 0),
+ ];
+ final contrast =
+ reds.reduce((a, b) => a > b ? a : b) -
+ reds.reduce((a, b) => a < b ? a : b);
+ expect(contrast, lessThan(30), reason: 'background at x=$start');
+ }
+ });
+
+ testWidgets(
+ 'Dock keeps larger backdrop details recognizable through its glass',
+ (tester) async {
+ final pixels = await _render(
+ tester,
+ GlassDock(selectedIndex: 0, onSelected: (_) {}),
+ stripes: true,
+ stripeWidth: 12,
+ preview: true,
+ );
+ for (final start in [24, 160]) {
+ final reds = [
+ for (var x = start; x < start + 40; x++) _channel(pixels, x, 52, 0),
+ ];
+ final contrast =
+ reds.reduce((a, b) => a > b ? a : b) -
+ reds.reduce((a, b) => a < b ? a : b);
+ // The original stripes have 180 levels of contrast. Both the base and
+ // selected lens retain over half, keeping these larger shapes readable.
+ expect(contrast, greaterThan(90), reason: 'background at x=$start');
+ }
+ },
+ );
+
+ testWidgets(
+ 'composed diffusion keeps edge refraction aligned when glass moves',
+ (tester) async {
+ final plain = await _render(
+ tester,
+ const GlassRefraction(strength: 0, child: SizedBox.expand()),
+ gradient: true,
+ );
+ final refracted = await _render(
+ tester,
+ const GlassRefraction(strength: 1, child: SizedBox.expand()),
+ gradient: true,
+ );
+ final translated = await _render(
+ tester,
+ const GlassRefraction(strength: 1, child: SizedBox.expand()),
+ gradient: true,
+ offset: const Offset(23, -37),
+ );
+ expect(
+ (_channel(refracted, 15, 34, 0) - _channel(plain, 15, 34, 0)).abs(),
+ greaterThan(3),
+ );
+ expect(
+ _channel(refracted, 132, 34, 0),
+ closeTo(_channel(plain, 132, 34, 0), 2),
+ );
+ for (final x in [15, 132, 248]) {
+ expect(
+ _channel(translated, x, 34, 0),
+ closeTo(_channel(refracted, x, 34, 0), 2),
+ );
+ }
+ },
+ skip: !ui.ImageFilter.isShaderFilterSupported,
+ );
+}
+
+int _channel(ByteData pixels, int x, int y, int channel) =>
+ pixels.getUint8((y * 264 + x) * 4 + channel);
+
+Future _render(
+ WidgetTester tester,
+ Widget glass, {
+ bool stripes = false,
+ double stripeWidth = 2,
+ bool gradient = false,
+ bool preview = false,
+ Offset offset = Offset.zero,
+}) async {
+ tester.view.physicalSize = const Size(800, 600);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
+ final key = GlobalKey(), glassKey = GlobalKey();
+ await tester.pumpWidget(
+ RepaintBoundary(
+ key: key,
+ child: MaterialApp(
+ home: Center(
+ child: Transform.translate(
+ offset: offset,
+ child: SizedBox(
+ key: glassKey,
+ width: 264,
+ height: 68,
+ child: Stack(
+ children: [
+ Positioned.fill(
+ child: CustomPaint(
+ painter: _Backdrop(stripes, gradient, stripeWidth),
+ ),
+ ),
+ Positioned.fill(child: glass),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ if (ui.ImageFilter.isShaderFilterSupported) {
+ // Native shader loading completes outside the widget test's fake async zone.
+ await tester.runAsync(
+ () => ui.FragmentProgram.fromAsset('shaders/liquid_glass.frag'),
+ );
+ }
+ await tester.pumpAndSettle();
+ if (ui.ImageFilter.isShaderFilterSupported) {
+ expect(find.byType(BackdropFilter), findsNothing);
+ }
+ return (await tester.runAsync(() async {
+ final boundary =
+ key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
+ final image = await boundary.toImage(pixelRatio: 1);
+ final data = (await image.toByteData(format: ui.ImageByteFormat.rawRgba))!;
+ final region = tester.getRect(find.byKey(glassKey));
+ if (preview && const bool.fromEnvironment('TP_BRAND_PREVIEW')) {
+ final recorder = ui.PictureRecorder();
+ ui.Canvas(recorder).drawImageRect(
+ image,
+ region,
+ const Rect.fromLTWH(0, 0, 792, 204),
+ Paint()..filterQuality = FilterQuality.high,
+ );
+ final picture = recorder.endRecording();
+ final previewImage = await picture.toImage(792, 204);
+ final png = (await previewImage.toByteData(
+ format: ui.ImageByteFormat.png,
+ ))!;
+ final output = File('../dist/screenshots/glass-frosted-dock.png');
+ await output.parent.create(recursive: true);
+ await output.writeAsBytes(png.buffer.asUint8List());
+ previewImage.dispose();
+ picture.dispose();
+ }
+ final cropped = ByteData(264 * 68 * 4);
+ for (var y = 0; y < 68; y++) {
+ for (var x = 0; x < 264; x++) {
+ final source =
+ ((region.top.round() + y) * image.width + region.left.round() + x) *
+ 4;
+ for (var channel = 0; channel < 4; channel++) {
+ cropped.setUint8(
+ (y * 264 + x) * 4 + channel,
+ data.getUint8(source + channel),
+ );
+ }
+ }
+ }
+ image.dispose();
+ return cropped;
+ }))!;
+}
+
+class _Backdrop extends CustomPainter {
+ const _Backdrop(this.stripes, this.gradient, this.stripeWidth);
+ final bool stripes;
+ final bool gradient;
+ final double stripeWidth;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ if (gradient) {
+ for (var x = 0; x < size.width; x++) {
+ canvas.drawRect(
+ Rect.fromLTWH(x.toDouble(), 0, 1, size.height),
+ Paint()..color = Color.fromARGB(255, 40 + (x * 0.7).round(), 70, 90),
+ );
+ }
+ return;
+ }
+ if (!stripes) {
+ canvas.drawRect(
+ Offset.zero & size,
+ Paint()..color = const Color(0xFF14B4E6),
+ );
+ return;
+ }
+ for (var x = 0.0; x < size.width; x += stripeWidth) {
+ canvas.drawRect(
+ Rect.fromLTWH(x, 0, stripeWidth, size.height),
+ Paint()
+ ..color = (x / stripeWidth).round().isEven
+ ? const Color(0xFFDC3C3C)
+ : const Color(0xFF28B4DC),
+ );
+ }
+ }
+
+ @override
+ bool shouldRepaint(_Backdrop oldDelegate) =>
+ oldDelegate.stripes != stripes ||
+ oldDelegate.gradient != gradient ||
+ oldDelegate.stripeWidth != stripeWidth;
+}
diff --git a/mobile/test/widgets/refraction_shader_test.dart b/mobile/test/widgets/refraction_shader_test.dart
index 325cd04..2b41538 100644
--- a/mobile/test/widgets/refraction_shader_test.dart
+++ b/mobile/test/widgets/refraction_shader_test.dart
@@ -58,4 +58,63 @@ void main() {
sourcePicture.dispose();
},
);
+
+ test(
+ 'glass rim reflects nearby backdrop light outside its transmitted ray',
+ () async {
+ const width = 160, height = 100;
+ final program = await ui.FragmentProgram.fromAsset(
+ 'shaders/liquid_glass.frag',
+ );
+ Future draw(bool brightEdge) async {
+ final sourceRecorder = ui.PictureRecorder();
+ final sourceCanvas = ui.Canvas(sourceRecorder);
+ sourceCanvas.drawRect(
+ const ui.Rect.fromLTWH(0, 0, 160, 100),
+ ui.Paint()..color = const ui.Color(0xFF28465A),
+ );
+ if (brightEdge) {
+ sourceCanvas.drawRect(
+ const ui.Rect.fromLTWH(0, 0, 5, 100),
+ ui.Paint()..color = const ui.Color(0xFF28E65A),
+ );
+ }
+ final sourcePicture = sourceRecorder.endRecording();
+ final source = await sourcePicture.toImage(width, height);
+ final shader = program.fragmentShader()
+ ..setFloat(0, width.toDouble())
+ ..setFloat(1, height.toDouble())
+ ..setFloat(2, 0)
+ ..setFloat(3, 0)
+ ..setFloat(4, width.toDouble())
+ ..setFloat(5, height.toDouble())
+ ..setFloat(6, 1)
+ ..setImageSampler(0, source);
+ final recorder = ui.PictureRecorder();
+ ui.Canvas(recorder).drawRect(
+ const ui.Rect.fromLTWH(0, 0, 160, 100),
+ ui.Paint()..shader = shader,
+ );
+ final picture = recorder.endRecording();
+ final image = await picture.toImage(width, height);
+ final data = (await image.toByteData())!;
+ image.dispose();
+ picture.dispose();
+ shader.dispose();
+ source.dispose();
+ sourcePicture.dispose();
+ return data;
+ }
+
+ final plain = await draw(false), reflected = await draw(true);
+ final rimGreen = (50 * width + 8) * 4 + 1;
+ final centreGreen = (50 * width + 80) * 4 + 1;
+ expect(
+ reflected.getUint8(rimGreen) - plain.getUint8(rimGreen),
+ greaterThan(2),
+ );
+ expect(reflected.getUint8(centreGreen), plain.getUint8(centreGreen));
+ expect(reflected.getUint8(rimGreen + 2), 255);
+ },
+ );
}
diff --git a/mobile/test/widgets/term_prompt_test.dart b/mobile/test/widgets/term_prompt_test.dart
index 3a29f6d..f180339 100644
--- a/mobile/test/widgets/term_prompt_test.dart
+++ b/mobile/test/widgets/term_prompt_test.dart
@@ -72,7 +72,7 @@ void main() {
await tester.tap(find.bySemanticsLabel('个人设置'));
await tester.pumpAndSettle();
expect(find.text('更新学期'), findsOneWidget);
- expect(find.text('RSchedule'), findsWidgets);
+ expect(find.text('TPSchedule'), findsWidgets);
c.dispose();
},
);
diff --git a/mobile/test/widgets/tp_branding_test.dart b/mobile/test/widgets/tp_branding_test.dart
new file mode 100644
index 0000000..0fdc966
--- /dev/null
+++ b/mobile/test/widgets/tp_branding_test.dart
@@ -0,0 +1,102 @@
+import 'dart:io';
+import 'dart:typed_data';
+import 'dart:ui' as ui;
+
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:hepan_everyday/app/tp_launch_page.dart';
+import 'package:hepan_everyday/widgets/tp_logo.dart';
+
+void main() {
+ testWidgets('logo preserves calendar, bindings and smile from supplied art', (
+ tester,
+ ) async {
+ final boundaryKey = GlobalKey();
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Center(
+ child: RepaintBoundary(
+ key: boundaryKey,
+ child: const TpLogo(size: 200),
+ ),
+ ),
+ ),
+ );
+ final pixels = await _capture(tester, boundaryKey, 'tpschedule-logo');
+ expect(_pixel(pixels, 200, 40, 100), [255, 210, 42, 255]);
+ expect(_pixel(pixels, 200, 73, 50), [255, 255, 255, 255]);
+ expect(_pixel(pixels, 200, 100, 115), [255, 255, 255, 255]);
+ final background = _pixel(pixels, 200, 10, 100);
+ expect(background[2], closeTo(255, 3));
+ expect(background[0], inInclusiveRange(42, 63));
+ expect(background[1], inInclusiveRange(108, 209));
+ });
+
+ testWidgets('foreground logo leaves its surround transparent', (
+ tester,
+ ) async {
+ final boundaryKey = GlobalKey();
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Center(
+ child: RepaintBoundary(
+ key: boundaryKey,
+ child: const TpLogo(size: 200, background: false),
+ ),
+ ),
+ ),
+ );
+ final pixels = await _capture(tester, boundaryKey);
+ expect(_pixel(pixels, 200, 10, 10)[3], 0);
+ expect(_pixel(pixels, 200, 40, 100), [255, 210, 42, 255]);
+ });
+
+ testWidgets('launch fills the screen with brand blue in dark mode', (
+ tester,
+ ) async {
+ final boundaryKey = GlobalKey();
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData.dark(),
+ home: RepaintBoundary(key: boundaryKey, child: const TpLaunchPage()),
+ ),
+ );
+ expect(find.text('TPSchedule'), findsOneWidget);
+ final logo = tester.widget(find.byType(TpLogo));
+ expect(logo.background, isFalse);
+ final pixels = await _capture(tester, boundaryKey, 'tpschedule-launch');
+ for (final point in [const Offset(10, 10), const Offset(790, 590)]) {
+ final pixel = _pixel(pixels, 800, point.dx.toInt(), point.dy.toInt());
+ expect(pixel[2], closeTo(255, 3));
+ expect(pixel[0], inInclusiveRange(42, 63));
+ expect(pixel[1], inInclusiveRange(108, 209));
+ expect(pixel[3], 255);
+ }
+ expect(tester.takeException(), isNull);
+ });
+}
+
+Future _capture(
+ WidgetTester tester,
+ GlobalKey key, [
+ String? previewName,
+]) async {
+ final boundary =
+ key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
+ return (await tester.runAsync(() async {
+ final image = await boundary.toImage(pixelRatio: 1);
+ final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
+ if (const bool.fromEnvironment('TP_BRAND_PREVIEW') && previewName != null) {
+ final png = await image.toByteData(format: ui.ImageByteFormat.png);
+ final output = File('../dist/screenshots/$previewName.png');
+ await output.parent.create(recursive: true);
+ await output.writeAsBytes(png!.buffer.asUint8List());
+ }
+ image.dispose();
+ return bytes!.buffer.asUint8List();
+ }))!;
+}
+
+List _pixel(Uint8List pixels, int width, int x, int y) =>
+ pixels.sublist((y * width + x) * 4, (y * width + x) * 4 + 4);
diff --git a/mobile/test/widgets/tp_startup_test.dart b/mobile/test/widgets/tp_startup_test.dart
new file mode 100644
index 0000000..e434571
--- /dev/null
+++ b/mobile/test/widgets/tp_startup_test.dart
@@ -0,0 +1,29 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:hepan_everyday/app/tp_startup.dart';
+
+void main() {
+ testWidgets(
+ 'startup shows brand while initializing and immediately proceeds when ready',
+ (tester) async {
+ final ready = Completer();
+ await tester.pumpWidget(
+ TpStartup(
+ ready: ready.future,
+ child: const MaterialApp(home: Scaffold(body: Text('已准备好课表'))),
+ ),
+ );
+ await tester.pump();
+ expect(find.text('TPSchedule'), findsOneWidget);
+ expect(find.text('已准备好课表'), findsNothing);
+ ready.complete();
+ await tester.pump();
+ await tester.pump();
+ expect(find.text('已准备好课表'), findsOneWidget);
+ await tester.pumpAndSettle();
+ expect(find.text('TPSchedule'), findsNothing);
+ },
+ );
+}
diff --git a/scripts/build-android.ps1 b/scripts/build-android.ps1
index 139232a..d3adeaa 100644
--- a/scripts/build-android.ps1
+++ b/scripts/build-android.ps1
@@ -1,15 +1,32 @@
-param([string]$ToolchainRoot = 'C:/Users/david/Desktop/app/.tools')
+param(
+ [string]$ToolchainRoot = 'C:/Users/david/Desktop/app/.tools',
+ [ValidateSet('debug', 'release')][string]$Mode = 'release'
+)
$ErrorActionPreference = 'Stop'
$env:HEPAN_TOOLCHAIN_ROOT=$ToolchainRoot
. "$PSScriptRoot/android-env.ps1"
$projectRoot=Split-Path $PSScriptRoot -Parent
Push-Location "$projectRoot/mobile"
try {
- flutter.bat build apk --debug
+ # Refresh local dependency metadata without network. The build then regenerates
+ # plugin registration for its mode, excluding test-only plugins from release.
+ flutter.bat pub get --offline
+ if($LASTEXITCODE -ne 0) { throw '本地依赖准备失败,未开始构建' }
+ flutter.bat build apk "--$Mode"
if($LASTEXITCODE -ne 0) { throw 'Android 构建失败,未复制 APK' }
- $built=Join-Path (Get-Location) 'build/app/outputs/flutter-apk/app-debug.apk'
+ $built=Join-Path (Get-Location) "build/app/outputs/flutter-apk/app-$Mode.apk"
if(!(Test-Path -LiteralPath $built)) { throw '本次构建未生成 APK' }
+ $taskSigner = Join-Path $env:ANDROID_HOME 'build-tools/36.0.0/apksigner.bat'
+ $taskSignature = & $taskSigner verify --print-certs $built
+ if ($LASTEXITCODE -ne 0) { throw 'APK 签名验证失败,未复制 APK' }
+ $taskExpectedCertificate = 'AF5C21B1BA1E48E461D8EA38DF0FE1C3653F64EB7E8E7728BAE6E5D988BA0D0A'
+ $taskSignatureText = $taskSignature -join "`n"
+ if ($taskSignatureText -notmatch 'certificate SHA-256 digest:\s*([a-fA-F0-9]{64})' -or
+ $Matches[1].ToUpperInvariant() -ne $taskExpectedCertificate) {
+ throw '签名证书与原证书不一致,未复制 APK。请保留原有签名文件,不要重新生成。'
+ }
New-Item -ItemType Directory -Force "$projectRoot/dist" | Out-Null
- Copy-Item -LiteralPath $built -Destination "$projectRoot/dist/RSchedule-debug.apk" -Force
- Get-Item -LiteralPath "$projectRoot/dist/RSchedule-debug.apk" | Select-Object FullName,Length,LastWriteTime
+ $taskArtifact = if ($Mode -eq 'release') { 'TPSchedule.apk' } else { 'TPSchedule-debug.apk' }
+ Copy-Item -LiteralPath $built -Destination "$projectRoot/dist/$taskArtifact" -Force
+ Get-Item -LiteralPath "$projectRoot/dist/$taskArtifact" | Select-Object FullName,Length,LastWriteTime
} finally { Pop-Location }
diff --git a/scripts/signing-info.py b/scripts/signing-info.py
new file mode 100644
index 0000000..13880e0
--- /dev/null
+++ b/scripts/signing-info.py
@@ -0,0 +1,125 @@
+"""Export public APK signing information and verify continuity. No private keys."""
+import argparse
+import hashlib
+import json
+import struct
+import zipfile
+from pathlib import Path
+
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.serialization import pkcs7
+from cryptography import x509
+
+
+def length_prefixed(data, offset=0):
+ size = struct.unpack_from(' len(data):
+ raise ValueError('Malformed APK signing record.')
+ return data[offset + 4:end], end
+
+
+def modern_certificate(apk):
+ # APK v2/v3 certificates live before the ZIP central directory, not META-INF.
+ with open(apk, 'rb') as stream:
+ stream.seek(0, 2)
+ size = stream.tell()
+ stream.seek(max(0, size - 65557))
+ tail = stream.read()
+ eocd = tail.rfind(b'PK\x05\x06')
+ if eocd < 0:
+ raise ValueError('Missing ZIP directory.')
+ central = struct.unpack_from(' central:
+ raise ValueError('Malformed APK signing block.')
+ stream.seek(central - block_size - 8)
+ block = stream.read(block_size + 8)
+ if struct.unpack_from('