From 204e945b2ed24913bc534503c879bcd13ab4a084 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 13 Oct 2026 22:08:20 +0800 Subject: [PATCH] Rebrand as TPSchedule and refine clear liquid glass dock --- README.md | 16 +- .../2026-10-13-tpschedule-brand-and-glass.md | 11 + docs/tpschedule-0.5-validation.md | 50 ++++ mobile/android/.gitignore | 1 + mobile/android/app/build.gradle.kts | 5 +- .../android/app/src/main/AndroidManifest.xml | 5 +- .../app}/CalendarBridge.kt | 2 +- .../app}/MainActivity.kt | 2 +- .../res/drawable-v21/launch_background.xml | 11 +- .../res/drawable/ic_tpschedule_foreground.xml | 8 + .../res/drawable/ic_tpschedule_launcher.xml | 15 + .../main/res/drawable/ic_tpschedule_logo.xml | 6 + .../res/drawable/ic_tpschedule_monochrome.xml | 8 + .../res/drawable/ic_tpschedule_splash.xml | 8 + .../main/res/drawable/launch_background.xml | 11 +- .../main/res/drawable/tpschedule_gradient.xml | 12 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../res/mipmap-anydpi-v33/ic_launcher.xml | 6 + .../main/res/mipmap-anydpi/ic_launcher.xml | 15 + .../src/main/res/values-night-v31/styles.xml | 13 + .../app/src/main/res/values-night/styles.xml | 23 +- .../app/src/main/res/values-v31/styles.xml | 13 + .../app/src/main/res/values/styles.xml | 19 +- .../src/main/res/values/tpschedule_colors.xml | 4 + mobile/assets/branding/APP.svg | 46 +++ mobile/lib/app/hepan_app.dart | 2 +- mobile/lib/app/tp_launch_page.dart | 45 +++ mobile/lib/app/tp_startup.dart | 48 ++++ mobile/lib/features/profile/profile_page.dart | 4 +- .../lib/features/profile/settings_page.dart | 2 +- mobile/lib/main.dart | 13 +- .../services/android_calendar_service.dart | 2 +- mobile/lib/widgets/glass_dock.dart | 43 ++- mobile/lib/widgets/glass_refraction.dart | 54 +++- mobile/lib/widgets/tp_logo.dart | 93 ++++++ mobile/pubspec.yaml | 6 +- mobile/shaders/liquid_glass.frag | 10 +- mobile/test/widgets/dock_motion_test.dart | 7 +- mobile/test/widgets/glass_material_test.dart | 270 ++++++++++++++++++ .../test/widgets/refraction_shader_test.dart | 59 ++++ mobile/test/widgets/term_prompt_test.dart | 2 +- mobile/test/widgets/tp_branding_test.dart | 102 +++++++ mobile/test/widgets/tp_startup_test.dart | 29 ++ scripts/build-android.ps1 | 27 +- scripts/signing-info.py | 125 ++++++++ 45 files changed, 1155 insertions(+), 103 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-13-tpschedule-brand-and-glass.md create mode 100644 docs/tpschedule-0.5-validation.md rename mobile/android/app/src/main/kotlin/com/{example/hepan_everyday => tpschedule/app}/CalendarBridge.kt (99%) rename mobile/android/app/src/main/kotlin/com/{example/hepan_everyday => tpschedule/app}/MainActivity.kt (95%) create mode 100644 mobile/android/app/src/main/res/drawable/ic_tpschedule_foreground.xml create mode 100644 mobile/android/app/src/main/res/drawable/ic_tpschedule_launcher.xml create mode 100644 mobile/android/app/src/main/res/drawable/ic_tpschedule_logo.xml create mode 100644 mobile/android/app/src/main/res/drawable/ic_tpschedule_monochrome.xml create mode 100644 mobile/android/app/src/main/res/drawable/ic_tpschedule_splash.xml create mode 100644 mobile/android/app/src/main/res/drawable/tpschedule_gradient.xml create mode 100644 mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 mobile/android/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml create mode 100644 mobile/android/app/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 mobile/android/app/src/main/res/values-night-v31/styles.xml create mode 100644 mobile/android/app/src/main/res/values-v31/styles.xml create mode 100644 mobile/android/app/src/main/res/values/tpschedule_colors.xml create mode 100644 mobile/assets/branding/APP.svg create mode 100644 mobile/lib/app/tp_launch_page.dart create mode 100644 mobile/lib/app/tp_startup.dart create mode 100644 mobile/lib/widgets/tp_logo.dart create mode 100644 mobile/test/widgets/glass_material_test.dart create mode 100644 mobile/test/widgets/tp_branding_test.dart create mode 100644 mobile/test/widgets/tp_startup_test.dart create mode 100644 scripts/signing-info.py 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('