Rebrand as TPSchedule and refine clear liquid glass dock

This commit is contained in:
Codex
2026-10-13 22:08:20 +08:00
parent 50fcc70e8e
commit 204e945b2e
45 changed files with 1155 additions and 103 deletions
+10 -6
View File
@@ -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过渡滑出屏幕底部,向上滚动恢复;个人设置页面保持显示。系统“减少动画”开启时减少切页与回弹动画。 三个图标标签页:课表、考试、个人设置。界面统一蓝色,三个页面标题对齐。透明蓝色液态玻璃风格悬浮 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),学生在学校原网页填写账号、密码和验证码;应用没有自己的密码表单,不读取或注入密码。首次认证完成后选择学校学期,确认后显示“正在查找课表,请稍候”,自动打开对应课表查询并导入保存。 在个人设置中打开学校账户。应用先访问 [研究生系统专用认证入口](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 必须与所选学期一致,取消或保存失败不改原数据。 学期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 回调仅允许准确的研究生系统域名。读取或保存失败保留已有数据,可在学校认证页面重试。 专用入口的原始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)。示例课表不覆盖本人数据,不创建通知或日历事件。 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 ```powershell
$env:HEPAN_TOOLCHAIN_ROOT='C:/Users/david/Desktop/app/.tools' $env:HEPAN_TOOLCHAIN_ROOT='C:/Users/david/Desktop/app/.tools'
@@ -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. 对比新旧证书摘要及公钥,导出备案可用的公开签名信息,明确调试签名的现状。新包不能直接升级旧应用,旧数据需重新导入。交付安装包、源码与说明。
+50
View File
@@ -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。
+1
View File
@@ -6,6 +6,7 @@ gradle-wrapper.jar
/local.properties /local.properties
GeneratedPluginRegistrant.java GeneratedPluginRegistrant.java
.cxx/ .cxx/
.kotlin/
# Remember to never publicly share your keystore. # Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore # See https://flutter.dev/to/reference-keystore
+2 -3
View File
@@ -5,7 +5,7 @@ plugins {
} }
android { android {
namespace = "com.example.hepan_everyday" namespace = "com.tpschedule.app"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
@@ -16,8 +16,7 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.tpschedule.app"
applicationId = "com.example.hepan_everyday"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 24 minSdk = 24
@@ -6,10 +6,11 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" /> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application <application
android:label="RSchedule" android:label="TPSchedule"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@drawable/ic_hepan_launcher"> android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher">
<receiver android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" android:exported="false" /> <receiver android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" android:exported="false" />
<receiver android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver" android:exported="false"> <receiver android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver" android:exported="false">
<intent-filter> <intent-filter>
@@ -1,4 +1,4 @@
package com.example.hepan_everyday package com.tpschedule.app
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
@@ -1,4 +1,4 @@
package com.example.hepan_everyday package com.tpschedule.app
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
@@ -1,12 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" /> <item android:drawable="@drawable/tpschedule_gradient" />
<item android:width="120dp" android:height="120dp" android:gravity="center" android:drawable="@drawable/ic_tpschedule_logo" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list> </layer-list>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<group android:scaleX="0.035" android:scaleY="0.035" android:translateX="18.855275" android:translateY="18.83165">
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFD22A" android:strokeWidth="217" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M425.1,514.64 H1583.18 A20,20 0,0 1,1603.18 534.64 V1566.53 A20,20 0,0 1,1583.18 1586.53 H425.1 A20,20 0,0 1,405.1 1566.53 V534.64 A20,20 0,0 1,425.1 514.64 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="150" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M731.72,339.92 H740.93 A27.65,27.65 0,0 1,768.58 367.57 V661.71 A27.65,27.65 0,0 1,740.93 689.36 H731.72 A27.65,27.65 0,0 1,704.07 661.71 V367.57 A27.65,27.65 0,0 1,731.72 339.92 Z M1277.56,339.92 H1286.77 A27.65,27.65 0,0 1,1314.42 367.57 V661.71 A27.65,27.65 0,0 1,1286.77 689.36 H1277.56 A27.65,27.65 0,0 1,1249.91 661.71 V367.57 A27.65,27.65 0,0 1,1277.56 339.92 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="185" android:strokeLineCap="butt" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M729.01,1004.81 c70.21,112.08 200.6,174.7 327.98,152.69 c122.48,-18.51 206.2,-123.19 222.28,-152.69" />
</group>
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt" android:width="108dp" android:height="108dp" android:viewportWidth="2008.27" android:viewportHeight="2009.62">
<path android:pathData="M0.5,0.5 H2007.77 V2009.12 H0.5 Z" android:strokeColor="#000000" android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient android:type="linear" android:startX="324.09" android:startY="1572.05" android:endX="1871.99" android:endY="280.93">
<item android:offset="0" android:color="#3FD1FF" />
<item android:offset="0.28" android:color="#38B3FF" />
<item android:offset="1" android:color="#2A6CFF" />
</gradient>
</aapt:attr>
</path>
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFD22A" android:strokeWidth="217" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M425.1,514.64 H1583.18 A20,20 0,0 1,1603.18 534.64 V1566.53 A20,20 0,0 1,1583.18 1586.53 H425.1 A20,20 0,0 1,405.1 1566.53 V534.64 A20,20 0,0 1,425.1 514.64 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="150" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M731.72,339.92 H740.93 A27.65,27.65 0,0 1,768.58 367.57 V661.71 A27.65,27.65 0,0 1,740.93 689.36 H731.72 A27.65,27.65 0,0 1,704.07 661.71 V367.57 A27.65,27.65 0,0 1,731.72 339.92 Z M1277.56,339.92 H1286.77 A27.65,27.65 0,0 1,1314.42 367.57 V661.71 A27.65,27.65 0,0 1,1286.77 689.36 H1277.56 A27.65,27.65 0,0 1,1249.91 661.71 V367.57 A27.65,27.65 0,0 1,1277.56 339.92 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="185" android:strokeLineCap="butt" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M729.01,1004.81 c70.21,112.08 200.6,174.7 327.98,152.69 c122.48,-18.51 206.2,-123.19 222.28,-152.69" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt" android:width="108dp" android:height="108dp" android:viewportWidth="2008.27" android:viewportHeight="2009.62">
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFD22A" android:strokeWidth="217" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M425.1,514.64 H1583.18 A20,20 0,0 1,1603.18 534.64 V1566.53 A20,20 0,0 1,1583.18 1586.53 H425.1 A20,20 0,0 1,405.1 1566.53 V534.64 A20,20 0,0 1,425.1 514.64 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="150" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M731.72,339.92 H740.93 A27.65,27.65 0,0 1,768.58 367.57 V661.71 A27.65,27.65 0,0 1,740.93 689.36 H731.72 A27.65,27.65 0,0 1,704.07 661.71 V367.57 A27.65,27.65 0,0 1,731.72 339.92 Z M1277.56,339.92 H1286.77 A27.65,27.65 0,0 1,1314.42 367.57 V661.71 A27.65,27.65 0,0 1,1286.77 689.36 H1277.56 A27.65,27.65 0,0 1,1249.91 661.71 V367.57 A27.65,27.65 0,0 1,1277.56 339.92 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="185" android:strokeLineCap="butt" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M729.01,1004.81 c70.21,112.08 200.6,174.7 327.98,152.69 c122.48,-18.51 206.2,-123.19 222.28,-152.69" />
</vector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<group android:scaleX="0.035" android:scaleY="0.035" android:translateX="18.855275" android:translateY="18.83165">
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="217" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M425.1,514.64 H1583.18 A20,20 0,0 1,1603.18 534.64 V1566.53 A20,20 0,0 1,1583.18 1586.53 H425.1 A20,20 0,0 1,405.1 1566.53 V534.64 A20,20 0,0 1,425.1 514.64 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="150" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M731.72,339.92 H740.93 A27.65,27.65 0,0 1,768.58 367.57 V661.71 A27.65,27.65 0,0 1,740.93 689.36 H731.72 A27.65,27.65 0,0 1,704.07 661.71 V367.57 A27.65,27.65 0,0 1,731.72 339.92 Z M1277.56,339.92 H1286.77 A27.65,27.65 0,0 1,1314.42 367.57 V661.71 A27.65,27.65 0,0 1,1286.77 689.36 H1277.56 A27.65,27.65 0,0 1,1249.91 661.71 V367.57 A27.65,27.65 0,0 1,1277.56 339.92 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="185" android:strokeLineCap="butt" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M729.01,1004.81 c70.21,112.08 200.6,174.7 327.98,152.69 c122.48,-18.51 206.2,-123.19 222.28,-152.69" />
</group>
</vector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="288dp" android:height="288dp" android:viewportWidth="288" android:viewportHeight="288">
<group android:scaleX="0.094" android:scaleY="0.094" android:translateX="49.61131" android:translateY="49.54786">
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFD22A" android:strokeWidth="217" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M425.1,514.64 H1583.18 A20,20 0,0 1,1603.18 534.64 V1566.53 A20,20 0,0 1,1583.18 1586.53 H425.1 A20,20 0,0 1,405.1 1566.53 V534.64 A20,20 0,0 1,425.1 514.64 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="150" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M731.72,339.92 H740.93 A27.65,27.65 0,0 1,768.58 367.57 V661.71 A27.65,27.65 0,0 1,740.93 689.36 H731.72 A27.65,27.65 0,0 1,704.07 661.71 V367.57 A27.65,27.65 0,0 1,731.72 339.92 Z M1277.56,339.92 H1286.77 A27.65,27.65 0,0 1,1314.42 367.57 V661.71 A27.65,27.65 0,0 1,1286.77 689.36 H1277.56 A27.65,27.65 0,0 1,1249.91 661.71 V367.57 A27.65,27.65 0,0 1,1277.56 339.92 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="185" android:strokeLineCap="butt" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M729.01,1004.81 c70.21,112.08 200.6,174.7 327.98,152.69 c122.48,-18.51 206.2,-123.19 222.28,-152.69" />
</group>
</vector>
@@ -1,12 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" /> <item android:drawable="@drawable/tpschedule_gradient" />
<item android:width="120dp" android:height="120dp" android:gravity="center" android:drawable="@drawable/ic_tpschedule_logo" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list> </layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt" android:width="108dp" android:height="108dp" android:viewportWidth="2008.27" android:viewportHeight="2009.62">
<path android:pathData="M0.5,0.5 H2007.77 V2009.12 H0.5 Z" android:strokeColor="#000000" android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient android:type="linear" android:startX="324.09" android:startY="1572.05" android:endX="1871.99" android:endY="280.93">
<item android:offset="0" android:color="#3FD1FF" />
<item android:offset="0.28" android:color="#38B3FF" />
<item android:offset="1" android:color="#2A6CFF" />
</gradient>
</aapt:attr>
</path>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/tpschedule_gradient" />
<foreground android:drawable="@drawable/ic_tpschedule_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/tpschedule_gradient" />
<foreground android:drawable="@drawable/ic_tpschedule_foreground" />
<monochrome android:drawable="@drawable/ic_tpschedule_monochrome" />
</adaptive-icon>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt" android:width="108dp" android:height="108dp" android:viewportWidth="2008.27" android:viewportHeight="2009.62">
<path android:pathData="M0.5,0.5 H2007.77 V2009.12 H0.5 Z" android:strokeColor="#000000" android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient android:type="linear" android:startX="324.09" android:startY="1572.05" android:endX="1871.99" android:endY="280.93">
<item android:offset="0" android:color="#3FD1FF" />
<item android:offset="0.28" android:color="#38B3FF" />
<item android:offset="1" android:color="#2A6CFF" />
</gradient>
</aapt:attr>
</path>
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFD22A" android:strokeWidth="217" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M425.1,514.64 H1583.18 A20,20 0,0 1,1603.18 534.64 V1566.53 A20,20 0,0 1,1583.18 1586.53 H425.1 A20,20 0,0 1,405.1 1566.53 V534.64 A20,20 0,0 1,425.1 514.64 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="150" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M731.72,339.92 H740.93 A27.65,27.65 0,0 1,768.58 367.57 V661.71 A27.65,27.65 0,0 1,740.93 689.36 H731.72 A27.65,27.65 0,0 1,704.07 661.71 V367.57 A27.65,27.65 0,0 1,731.72 339.92 Z M1277.56,339.92 H1286.77 A27.65,27.65 0,0 1,1314.42 367.57 V661.71 A27.65,27.65 0,0 1,1286.77 689.36 H1277.56 A27.65,27.65 0,0 1,1249.91 661.71 V367.57 A27.65,27.65 0,0 1,1277.56 339.92 Z" />
<path android:fillColor="@android:color/transparent" android:strokeColor="#FFFFFF" android:strokeWidth="185" android:strokeLineCap="butt" android:strokeLineJoin="miter" android:strokeMiterLimit="10" android:pathData="M729.01,1004.81 c70.21,112.08 200.6,174.7 327.98,152.69 c122.48,-18.51 206.2,-123.19 222.28,-152.69" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Android 12 requires a solid system splash; Flutter continues the SVG gradient. -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/tpschedule_blue</item>
<item name="android:windowSplashScreenBackground">@color/tpschedule_blue</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_tpschedule_splash</item>
<item name="android:statusBarColor">@color/tpschedule_blue</item>
<item name="android:navigationBarColor">@color/tpschedule_blue</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item>
</style>
</resources>
@@ -1,18 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on --> <style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:statusBarColor">@color/tpschedule_blue</item>
<item name="android:navigationBarColor">@color/tpschedule_blue</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
This theme determines the color of the Android Window while your <item name="android:windowBackground">@color/tpschedule_blue</item>
Flutter UI initializes, as well as behind your Flutter UI while its <item name="android:statusBarColor">@color/tpschedule_blue</item>
running. <item name="android:navigationBarColor">@color/tpschedule_blue</item>
<item name="android:windowLightStatusBar">false</item>
This Theme is only used starting with V2 of Flutter's Android embedding. --> <item name="android:windowLightNavigationBar">false</item>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style> </style>
</resources> </resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Android 12 requires a solid system splash; Flutter continues the SVG gradient. -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/tpschedule_blue</item>
<item name="android:windowSplashScreenBackground">@color/tpschedule_blue</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_tpschedule_splash</item>
<item name="android:statusBarColor">@color/tpschedule_blue</item>
<item name="android:navigationBarColor">@color/tpschedule_blue</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item>
</style>
</resources>
@@ -1,18 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar"> <style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:statusBarColor">@color/tpschedule_blue</item>
<item name="android:navigationBarColor">@color/tpschedule_blue</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item> <item name="android:windowBackground">@color/tpschedule_blue</item>
<item name="android:statusBarColor">@color/tpschedule_blue</item>
<item name="android:navigationBarColor">@color/tpschedule_blue</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowLightNavigationBar">false</item>
</style> </style>
</resources> </resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="tpschedule_blue">#38B3FF</color>
</resources>
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 2008.27 2009.62">
<defs>
<style>
.cls-1 {
stroke-width: 185px;
}
.cls-1, .cls-2, .cls-3 {
fill: none;
}
.cls-1, .cls-2, .cls-3, .cls-4 {
stroke-miterlimit: 10;
}
.cls-1, .cls-3 {
stroke: #fff;
}
.cls-2 {
stroke: #ffd22a;
stroke-width: 217px;
}
.cls-3 {
stroke-width: 150px;
}
.cls-4 {
fill: url(#_未命名的渐变_7);
stroke: #000;
}
</style>
<linearGradient id="_未命名的渐变_7" data-name="未命名的渐变 7" x1="324.09" y1="1572.05" x2="1871.99" y2="280.93" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#3fd1ff"/>
<stop offset=".28" stop-color="#38b3ff"/>
<stop offset="1" stop-color="#2a6cff"/>
</linearGradient>
</defs>
<rect class="cls-4" x=".5" y=".5" width="2007.27" height="2008.62"/>
<rect class="cls-2" x="405.1" y="514.64" width="1198.08" height="1071.89" rx="20" ry="20"/>
<rect class="cls-3" x="704.07" y="339.92" width="64.51" height="349.44" rx="27.65" ry="27.65"/>
<rect class="cls-3" x="1249.91" y="339.92" width="64.51" height="349.44" rx="27.65" ry="27.65"/>
<path class="cls-1" d="M729.01,1004.81c70.21,112.08,200.6,174.7,327.98,152.69,122.48-18.51,206.2-123.19,222.28-152.69"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+1 -1
View File
@@ -28,7 +28,7 @@ class HepanApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) => MaterialApp( Widget build(BuildContext context) => MaterialApp(
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
title: 'RSchedule', title: 'TPSchedule',
theme: hepanTheme(), theme: hepanTheme(),
locale: const Locale('zh', 'CN'), locale: const Locale('zh', 'CN'),
supportedLocales: const [Locale('zh', 'CN')], supportedLocales: const [Locale('zh', 'CN')],
+45
View File
@@ -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<SystemUiOverlayStyle>(
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,
),
),
],
),
),
),
),
),
);
}
+48
View File
@@ -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<void> ready;
final Widget child;
@override
Widget build(BuildContext context) => FutureBuilder<void>(
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<SystemUiOverlayStyle>(
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(),
),
);
},
);
}
@@ -70,7 +70,7 @@ class ProfilePage extends StatelessWidget {
), ),
children: [ children: [
const Text( const Text(
'RSchedule', 'TPSchedule',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -220,7 +220,7 @@ class ProfilePage extends StatelessWidget {
child: Column( child: Column(
children: [ children: [
const Text( const Text(
'RSchedule', 'TPSchedule',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600), style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -94,7 +94,7 @@ class _SettingsPageState extends State<SettingsPage> {
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('关闭日历同步'), title: const Text('关闭日历同步'),
content: const Text('是否删除Riverside Schedule 创建的课程日程?其他日程不会受影响。'), content: const Text('是否删除 TPSchedule 创建的课程日程?其他日程不会受影响。'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(context, false),
+8 -5
View File
@@ -4,6 +4,7 @@ import 'package:timezone/data/latest.dart' as tzdata;
import 'app/app_controller.dart'; import 'app/app_controller.dart';
import 'app/hepan_app.dart'; import 'app/hepan_app.dart';
import 'app/tp_startup.dart';
import 'data/file_snapshot_store.dart'; import 'data/file_snapshot_store.dart';
import 'services/local_notification_service.dart'; import 'services/local_notification_service.dart';
import 'services/android_calendar_service.dart'; import 'services/android_calendar_service.dart';
@@ -33,12 +34,14 @@ Future<void> main() async {
AndroidCalendarService(MethodCalendarGateway()), AndroidCalendarService(MethodCalendarGateway()),
DateTime.now, DateTime.now,
); );
await controller.initialize();
runApp( runApp(
buildApplication( TpStartup(
controller, ready: controller.initialize(),
notificationSelection: selection, child: buildApplication(
school: SchoolSession(), controller,
notificationSelection: selection,
school: SchoolSession(),
),
), ),
); );
} }
@@ -87,7 +87,7 @@ class AndroidCalendarService extends CalendarService {
calendarId: calendarId, calendarId: calendarId,
title: occurrence.course.name, title: occurrence.course.name,
room: occurrence.course.room, room: occurrence.course.room,
description: '任课老师:${occurrence.course.teacher}\nRiverside Schedule', description: '任课老师:${occurrence.course.teacher}\nTPSchedule',
startMs: occurrence.start.millisecondsSinceEpoch, startMs: occurrence.start.millisecondsSinceEpoch,
endMs: occurrence.end.millisecondsSinceEpoch, endMs: occurrence.end.millisecondsSinceEpoch,
), ),
+30 -13
View File
@@ -126,11 +126,11 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
colors: lens colors: lens
? [ ? [
const Color(0xFFD8DBE0) const Color(0xFFD8DBE0)
.withValues(alpha: 0.72 * (1 - pressure * 0.91)), .withValues(alpha: 0.14 * (1 - pressure * 0.82)),
const Color(0xFFC3C7CE) const Color(0xFFC3C7CE)
.withValues(alpha: 0.60 * (1 - pressure * 0.93)), .withValues(alpha: 0.08 * (1 - pressure * 0.82)),
const Color(0xFFE5E7EB) 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), Colors.white.withValues(alpha: 0.028),
@@ -145,7 +145,8 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(40), borderRadius: BorderRadius.circular(40),
child: GlassRefraction( 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, child: content,
), ),
); );
@@ -177,8 +178,8 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
borderRadius: BorderRadius.circular(40), borderRadius: BorderRadius.circular(40),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: _blue.withValues(alpha: 0.035), color: const Color(0xFF4C6078).withValues(alpha: 0.075),
blurRadius: 12, blurRadius: 16,
offset: const Offset(0, 5), offset: const Offset(0, 5),
), ),
], ],
@@ -288,15 +289,14 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
); );
} }
/// Thin paired rims and directional glints suggest rounded optical thickness. /// A diffuse silver bevel and thin directional glints show optical thickness.
/// All strong highlights stay on the edge; the centre remains clear. /// The painted highlights stay on the edge so background colour passes through.
class _GlassRimPainter extends CustomPainter { class _GlassRimPainter extends CustomPainter {
const _GlassRimPainter({required this.lens}); const _GlassRimPainter({required this.lens});
final bool lens; final bool lens;
static const _blue = Color(0xFF007AFF);
@override @override
void paint(Canvas canvas, Size size) { 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 bounds = Offset.zero & size;
final outer = RRect.fromRectAndRadius( final outer = RRect.fromRectAndRadius(
bounds.deflate(0.7), bounds.deflate(0.7),
@@ -306,6 +306,23 @@ class _GlassRimPainter extends CustomPainter {
bounds.deflate(2.2), bounds.deflate(2.2),
Radius.circular(size.height / 2 - 1.5), 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( canvas.drawRRect(
outer, outer,
Paint() Paint()
@@ -336,9 +353,9 @@ class _GlassRimPainter extends CustomPainter {
bounds.topLeft, bounds.topLeft,
bounds.bottomRight, bounds.bottomRight,
[ [
Colors.white.withValues(alpha: 0.30), Colors.white.withValues(alpha: 0.20),
edge.withValues(alpha: lens ? 0.13 : 0.07), 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], [0.0, 0.5, 1.0],
), ),
@@ -362,7 +379,7 @@ class _GlassRimPainter extends CustomPainter {
Offset(size.width, 0), Offset(size.width, 0),
[ [
Colors.white.withValues(alpha: 0), Colors.white.withValues(alpha: 0),
Colors.white.withValues(alpha: 0.65), Colors.white.withValues(alpha: 0.50),
Colors.white.withValues(alpha: 0), Colors.white.withValues(alpha: 0),
], ],
[0.0, 0.5, 1.0], [0.0, 0.5, 1.0],
+43 -11
View File
@@ -8,9 +8,13 @@ class GlassRefraction extends StatefulWidget {
const GlassRefraction({ const GlassRefraction({
super.key, super.key,
required this.strength, required this.strength,
this.blurSigma = 2,
required this.child, required this.child,
}); });
final double strength; final double strength;
/// Optical softness in logical pixels, independent of display density.
final double blurSigma;
final Widget child; final Widget child;
@override @override
State<GlassRefraction> createState() => _GlassRefractionState(); State<GlassRefraction> createState() => _GlassRefractionState();
@@ -18,6 +22,7 @@ class GlassRefraction extends StatefulWidget {
class _GlassRefractionState extends State<GlassRefraction> { class _GlassRefractionState extends State<GlassRefraction> {
static Future<ui.FragmentProgram>? _program; static Future<ui.FragmentProgram>? _program;
static ui.FragmentProgram? _loadedProgram;
ui.FragmentShader? _shader; ui.FragmentShader? _shader;
@override @override
void initState() { void initState() {
@@ -27,12 +32,12 @@ class _GlassRefractionState extends State<GlassRefraction> {
Future<void> _load() async { Future<void> _load() async {
try { try {
final program = await (_program ??= ui.FragmentProgram.fromAsset( final program = _loadedProgram ??= await (_program ??=
'shaders/liquid_glass.frag', ui.FragmentProgram.fromAsset('shaders/liquid_glass.frag'));
));
if (mounted) setState(() => _shader = program.fragmentShader()); if (mounted) setState(() => _shader = program.fragmentShader());
} catch (error) { } catch (error) {
_program = null; _program = null;
_loadedProgram = null;
debugPrint('Glass refraction unavailable: $error'); debugPrint('Glass refraction unavailable: $error');
} }
} }
@@ -46,12 +51,17 @@ class _GlassRefractionState extends State<GlassRefraction> {
@override @override
Widget build(BuildContext context) => _shader == null Widget build(BuildContext context) => _shader == null
? BackdropFilter( ? 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, child: widget.child,
) )
: _LiveGlassFilter( : _LiveGlassFilter(
shader: _shader!, shader: _shader!,
strength: widget.strength, strength: widget.strength,
blurSigma: widget.blurSigma,
pixelRatio: MediaQuery.devicePixelRatioOf(context), pixelRatio: MediaQuery.devicePixelRatioOf(context),
child: widget.child, child: widget.child,
); );
@@ -61,29 +71,41 @@ class _LiveGlassFilter extends SingleChildRenderObjectWidget {
const _LiveGlassFilter({ const _LiveGlassFilter({
required this.shader, required this.shader,
required this.strength, required this.strength,
required this.blurSigma,
required this.pixelRatio, required this.pixelRatio,
required super.child, required super.child,
}); });
final ui.FragmentShader shader; final ui.FragmentShader shader;
final double strength, pixelRatio; final double strength, blurSigma, pixelRatio;
@override @override
_RenderLiveGlass createRenderObject(BuildContext context) => _RenderLiveGlass createRenderObject(BuildContext context) =>
_RenderLiveGlass(shader, strength, pixelRatio); _RenderLiveGlass(shader, strength, blurSigma, pixelRatio);
@override @override
void updateRenderObject( void updateRenderObject(
BuildContext context, BuildContext context,
_RenderLiveGlass renderObject, _RenderLiveGlass renderObject,
) => renderObject.update(shader, strength, pixelRatio); ) => renderObject.update(shader, strength, blurSigma, pixelRatio);
} }
class _RenderLiveGlass extends RenderProxyBox { class _RenderLiveGlass extends RenderProxyBox {
_RenderLiveGlass(this._shader, this._strength, this._pixelRatio); _RenderLiveGlass(
this._shader,
this._strength,
this._blurSigma,
this._pixelRatio,
);
ui.FragmentShader _shader; ui.FragmentShader _shader;
double _strength, _pixelRatio; double _strength, _blurSigma, _pixelRatio;
final _layer = LayerHandle<BackdropFilterLayer>(); final _layer = LayerHandle<BackdropFilterLayer>();
void update(ui.FragmentShader shader, double strength, double pixelRatio) { void update(
ui.FragmentShader shader,
double strength,
double blurSigma,
double pixelRatio,
) {
_shader = shader; _shader = shader;
_strength = strength; _strength = strength;
_blurSigma = blurSigma;
_pixelRatio = pixelRatio; _pixelRatio = pixelRatio;
markNeedsPaint(); markNeedsPaint();
} }
@@ -103,7 +125,17 @@ class _RenderLiveGlass extends RenderProxyBox {
..setFloat(5, size.height * _pixelRatio) ..setFloat(5, size.height * _pixelRatio)
..setFloat(6, _strength); ..setFloat(6, _strength);
_layer.layer ??= BackdropFilterLayer(); _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); context.pushLayer(_layer.layer!, super.paint, offset);
} }
+93
View File
@@ -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;
}
+4 -2
View File
@@ -1,7 +1,7 @@
name: hepan_everyday name: hepan_everyday
description: RSchedule 研究生课表 description: TPSchedule 学生课表
publish_to: none publish_to: none
version: 0.4.0+7 version: 0.5.0+8
environment: environment:
sdk: ^3.13.5 sdk: ^3.13.5
dependencies: dependencies:
@@ -24,5 +24,7 @@ dev_dependencies:
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
flutter: flutter:
uses-material-design: true uses-material-design: true
assets:
- assets/branding/
shaders: shaders:
- shaders/liquid_glass.frag - shaders/liquid_glass.frag
+8 -2
View File
@@ -34,8 +34,14 @@ void main() {
float dispersion = u_strength * rim * 0.65; float dispersion = u_strength * rim * 0.65;
colour.r = texture(u_background, sampleUV(ray + normal * dispersion)).r; colour.r = texture(u_background, sampleUV(ray + normal * dispersion)).r;
colour.b = texture(u_background, sampleUV(ray - normal * dispersion)).b; colour.b = texture(u_background, sampleUV(ray - normal * dispersion)).b;
float reflection = pow(rim, 4.0) * u_strength * 0.10 // The curved rim catches nearby background light as well as transmitting it.
* max(0.0, dot(normal, normalize(vec2(-0.5, -0.8)))); // 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)); colour.rgb = min(colour.rgb + reflection * colour.a, vec3(colour.a));
frag_color = colour; frag_color = colour;
} }
+1 -6
View File
@@ -86,12 +86,7 @@ void main() {
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final lens = find final lens = find.byType(GlassRefraction).last;
.descendant(
of: find.byType(GlassDock),
matching: find.byType(BackdropFilter),
)
.last;
final resting = tester.getSize(lens); final resting = tester.getSize(lens);
final gesture = await tester.startGesture( final gesture = await tester.startGesture(
tester.getCenter(find.byKey(const Key('dock-cursor'))), tester.getCenter(find.byKey(const Key('dock-cursor'))),
@@ -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<ByteData> _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;
}
@@ -58,4 +58,63 @@ void main() {
sourcePicture.dispose(); 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<ByteData> 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);
},
);
} }
+1 -1
View File
@@ -72,7 +72,7 @@ void main() {
await tester.tap(find.bySemanticsLabel('个人设置')); await tester.tap(find.bySemanticsLabel('个人设置'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('更新学期'), findsOneWidget); expect(find.text('更新学期'), findsOneWidget);
expect(find.text('RSchedule'), findsWidgets); expect(find.text('TPSchedule'), findsWidgets);
c.dispose(); c.dispose();
}, },
); );
+102
View File
@@ -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<TpLogo>(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<Uint8List> _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<int> _pixel(Uint8List pixels, int width, int x, int y) =>
pixels.sublist((y * width + x) * 4, (y * width + x) * 4 + 4);
+29
View File
@@ -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<void>();
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);
},
);
}
+22 -5
View File
@@ -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' $ErrorActionPreference = 'Stop'
$env:HEPAN_TOOLCHAIN_ROOT=$ToolchainRoot $env:HEPAN_TOOLCHAIN_ROOT=$ToolchainRoot
. "$PSScriptRoot/android-env.ps1" . "$PSScriptRoot/android-env.ps1"
$projectRoot=Split-Path $PSScriptRoot -Parent $projectRoot=Split-Path $PSScriptRoot -Parent
Push-Location "$projectRoot/mobile" Push-Location "$projectRoot/mobile"
try { 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' } 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' } 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 New-Item -ItemType Directory -Force "$projectRoot/dist" | Out-Null
Copy-Item -LiteralPath $built -Destination "$projectRoot/dist/RSchedule-debug.apk" -Force $taskArtifact = if ($Mode -eq 'release') { 'TPSchedule.apk' } else { 'TPSchedule-debug.apk' }
Get-Item -LiteralPath "$projectRoot/dist/RSchedule-debug.apk" | Select-Object FullName,Length,LastWriteTime Copy-Item -LiteralPath $built -Destination "$projectRoot/dist/$taskArtifact" -Force
Get-Item -LiteralPath "$projectRoot/dist/$taskArtifact" | Select-Object FullName,Length,LastWriteTime
} finally { Pop-Location } } finally { Pop-Location }
+125
View File
@@ -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('<I', data, offset)[0]
end = offset + 4 + size
if end > 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('<I', tail, eocd + 16)[0]
stream.seek(central - 24)
footer = stream.read(24)
if footer[8:] != b'APK Sig Block 42':
return None
block_size = struct.unpack_from('<Q', footer)[0]
if block_size < 24 or block_size + 8 > central:
raise ValueError('Malformed APK signing block.')
stream.seek(central - block_size - 8)
block = stream.read(block_size + 8)
if struct.unpack_from('<Q', block)[0] != block_size:
raise ValueError('APK signing block sizes differ.')
offset = 8
while offset < len(block) - 24:
pair_size = struct.unpack_from('<Q', block, offset)[0]
offset += 8
pair = block[offset:offset + pair_size]
if pair_size < 4 or len(pair) != pair_size:
raise ValueError('Malformed APK signer pair.')
scheme = struct.unpack_from('<I', pair)[0]
if scheme in (0x7109871A, 0xF05368C0, 0x1B93AD61):
signers, _ = length_prefixed(pair[4:])
signer, signer_end = length_prefixed(signers)
if signer_end != len(signers):
raise ValueError('Multiple APK signers are not supported by this report.')
signed_data, _ = length_prefixed(signer)
_, digest_end = length_prefixed(signed_data)
certificates, _ = length_prefixed(signed_data, digest_end)
cert_der, _ = length_prefixed(certificates)
return x509.load_der_x509_certificate(cert_der)
offset += pair_size
return None
def certificate(apk):
modern = modern_certificate(apk)
if modern is not None:
return modern
with zipfile.ZipFile(apk) as archive:
blocks = [name for name in archive.namelist()
if name.upper().startswith('META-INF/')
and name.upper().endswith(('.RSA', '.EC', '.DSA'))]
if len(blocks) != 1:
raise ValueError('Expected one v1 signing certificate block; verify APK with apksigner.')
certificates = pkcs7.load_der_pkcs7_certificates(archive.read(blocks[0]))
if len(certificates) != 1:
raise ValueError('Expected one signing certificate, not a certificate chain.')
return certificates[0]
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--previous', required=True)
parser.add_argument('--current', required=True)
parser.add_argument('--output-dir', required=True)
args = parser.parse_args()
previous = certificate(args.previous)
current = certificate(args.current)
old_der = previous.public_bytes(serialization.Encoding.DER)
der = current.public_bytes(serialization.Encoding.DER)
public = current.public_key().public_bytes(
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
if old_der != der:
raise ValueError('Signing certificate changed. Existing signing material must be preserved.')
output = Path(args.output_dir)
output.mkdir(parents=True, exist_ok=True)
details = {
'appName': 'TPSchedule',
'applicationId': 'com.tpschedule.app',
'certificateUnchanged': True,
'subject': current.subject.rfc4514_string(),
'certificateSHA256': hashlib.sha256(der).hexdigest().upper(),
'certificateSHA1': hashlib.sha1(der).hexdigest().upper(),
'certificateMD5': hashlib.md5(der).hexdigest().upper(),
'publicKeySHA256': hashlib.sha256(public).hexdigest().upper(),
'publicKeyMD5': hashlib.md5(public).hexdigest().upper(),
'publicKeySPKIHex': public.hex().upper(),
'publicKeyBits': current.public_key().key_size,
'certificateNote': 'Existing Android Debug certificate preserved; store/filing acceptance must be checked.',
}
(output / 'TPSchedule-signing-info.json').write_text(
json.dumps(details, ensure_ascii=False, indent=2), encoding='utf-8')
(output / 'TPSchedule-signing-certificate.pem').write_bytes(
current.public_bytes(serialization.Encoding.PEM))
(output / 'TPSchedule-public-key.pem').write_bytes(
current.public_key().public_bytes(serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo))
print(json.dumps({'certificateUnchanged': True,
'certificateSHA256': details['certificateSHA256'],
'publicFiles': str(output)}, ensure_ascii=True))
if __name__ == '__main__':
main()