Rebrand as TPSchedule and refine clear liquid glass dock
This commit is contained in:
@@ -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. 对比新旧证书摘要及公钥,导出备案可用的公开签名信息,明确调试签名的现状。新包不能直接升级旧应用,旧数据需重新导入。交付安装包、源码与说明。
|
||||||
@@ -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。
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
-1
@@ -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
-1
@@ -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>
|
||||||
@@ -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 |
@@ -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')],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user