Compare commits
10
Commits
8abb132bc3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21b0df9e33 | ||
|
|
8713bce0ee | ||
|
|
23c1a58ca3 | ||
|
|
dc71e411e9 | ||
|
|
93bcfd534c | ||
|
|
c5cfbced5c | ||
|
|
6604f31d98 | ||
|
|
9da5864d29 | ||
|
|
fbcb7fb637 | ||
|
|
db953b8b3d |
@@ -1,10 +1,10 @@
|
|||||||
# TPSchedule
|
# TPSchedule
|
||||||
|
|
||||||
当前Android版本0.7.8+26:本科和研究生课表/考试均可选附带HTML反馈,保留原签名,个人页展示蜀ICP备2025169276号-5A。详见[0.7.8验证记录](docs/tpschedule-0.7.8-validation.md)及[反馈服务更新说明](server/feedback/html-diagnostic-update.md)。0.7.6起支持启动/手动检查更新与浏览器下载,附带[独立发布后台](server/updates/README.md)。
|
当前Android版本0.7.10+36:缺教室保留课程并标为待定,研究生增加格式兼容和具体错误诊断,见[本次记录](docs/tpschedule-0.7.10-import-diagnostics.md)。构建35:点按更快到位,缩短回弹和页面过渡,见[发布记录](docs/tpschedule-0.7.9-release.md)。构建34:未选中灰色、选中蓝色,增强乳白透射与滑块对图标本身的折射,见[本次记录](docs/tpschedule-0.7.8-dock-symbol-optics.md)。构建33:修复缓存仍在但系统触发信息丢失时跳过提醒重建的问题,保留启动/暖启动恢复前的诊断;Android 16深度Doze及rare分组测试已实际投递,vivo几小时长待机根因仍待新诊断确认。见[本次记录](docs/tpschedule-0.7.8-reminder-idle.md)。构建32:点按先放大再弹跳、底座方向跟随、边缘外溢及柔软光泽,见[本次记录](docs/tpschedule-0.7.8-dock-bloom.md)。构建31提供滑块移动中持续Q弹、前缘牵拉和弹性跟随,玻璃材质减薄,见[本次记录](docs/tpschedule-0.7.8-dock-drag.md)。构建30提供增强点按挤压回弹、整条Dock随拖动方向弹动、图标到位动画及乳白折射光泽,见[本次记录](docs/tpschedule-0.7.8-dock-spring.md)。构建29提供Dock点按/拖动弹性与Android平板横竖屏、分屏适配,见[本次验证记录](docs/tpschedule-0.7.8-dock-tablet.md)。构建28已提前建立通知类别、区分通知总开关和类别状态,见[通知排查记录](docs/tpschedule-0.7.8-notification-permissions.md)。用户手机已通过开启通知权限并重测确认恢复后台通知。Dock乳白透光层和边缘折射继续保留,见[材质优化记录](docs/tpschedule-0.7.8-dock-refinement.md)。本科/研究生HTML反馈见[0.7.8功能记录](docs/tpschedule-0.7.8-validation.md)及[反馈服务说明](server/feedback/html-diagnostic-update.md),更新管理后台见[部署说明](server/updates/README.md)。
|
||||||
|
|
||||||
0.7.0补充:考试页查询类型与卡片保持16像素间距;反馈问题和建议可自选附带最近10分钟的登录/读取/提醒诊断日志。默认不发送,不含密码、Cookie或课表内容,单独作为邮件文本附件。服务端需按[日志附件更新说明](server/feedback/diagnostic-log-update.md)手动更新;真实学校登录及SMTP未由本次离线测试代替验证。玻璃取样偏移仍在调查,未纳入本次修复结论。
|
0.7.0补充:考试页查询类型与卡片保持16像素间距;反馈问题和建议可自选附带最近10分钟的登录/读取/提醒诊断日志。默认不发送,不含密码、Cookie或课表内容,单独作为邮件文本附件。服务端需按[日志附件更新说明](server/feedback/diagnostic-log-update.md)手动更新;真实学校登录及SMTP未由本次离线测试代替验证。玻璃取样偏移仍在调查,未纳入本次修复结论。
|
||||||
|
|
||||||
学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.8+26,Android 7.0 及以上。
|
学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.10+36,Android 7.0 及以上。
|
||||||
|
|
||||||
使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
|
使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Android 首版验证记录
|
# Android 首版验证记录
|
||||||
|
|
||||||
当前版本为0.7.8+26,本科/研究生HTML反馈及签名、备案信息见[0.7.8记录](tpschedule-0.7.8-validation.md)。应用更新检查与后台说明见[0.7.6记录](tpschedule-0.7.6-updates.md)。本科考试兼容的验证见[0.7.5记录](tpschedule-0.7.5-validation.md)。两例真实课表缺失字段问题待新页面定位。本科接入记录见[0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。
|
当前版本为0.7.10+36,导入日志定位及修复见[本次记录](tpschedule-0.7.10-import-diagnostics.md)。构建35的点按节奏及发布验证见[本次记录](tpschedule-0.7.9-release.md)。构建34的灰蓝导航、乳白透射及图标折射见[本次记录](tpschedule-0.7.8-dock-symbol-optics.md)。构建33的提醒恢复与待机诊断见[本次记录](tpschedule-0.7.8-reminder-idle.md)。构建32的点按放大、外溢及柔软材质见[本次记录](tpschedule-0.7.8-dock-bloom.md)。构建31的移动中Q弹、牵拉及减薄见[本次记录](tpschedule-0.7.8-dock-drag.md)。构建30的点按/方向弹动及乳白材质见[本次记录](tpschedule-0.7.8-dock-spring.md)。构建29的Dock弹性与Android平板验证见[本次记录](tpschedule-0.7.8-dock-tablet.md)。构建28的通知权限恢复、通道初始化及真实后台触发见[通知记录](tpschedule-0.7.8-notification-permissions.md)。Dock乳白透光材质见[优化记录](tpschedule-0.7.8-dock-refinement.md)。本科/研究生HTML反馈及签名、备案信息见[0.7.8功能记录](tpschedule-0.7.8-validation.md)。应用更新检查与后台说明见[0.7.6记录](tpschedule-0.7.6-updates.md)。本科考试兼容的验证见[0.7.5记录](tpschedule-0.7.5-validation.md)。两例真实课表缺失字段问题待新页面定位。本科接入记录见[0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。
|
||||||
|
|
||||||
日期:2026-10-13。测试环境:Flutter 3.47.7 / Dart 3.13.5 / JDK 17;Android 16 API 36,Pixel 7 模拟器,1080×2400。首版版本 0.1.0+1,包 ID com.example.hepan_everyday,最低 Android 7.0(API 24)。
|
日期:2026-10-13。测试环境:Flutter 3.47.7 / Dart 3.13.5 / JDK 17;Android 16 API 36,Pixel 7 模拟器,1080×2400。首版版本 0.1.0+1,包 ID com.example.hepan_everyday,最低 Android 7.0(API 24)。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Dock 与平板实现计划
|
||||||
|
|
||||||
|
本会话直接实现。版本保留0.7.8,构建号递增29。旧APK、登记备份和密钥保持原样。
|
||||||
|
|
||||||
|
1. 先修改点按动画测试为新需求,增加连续反向点击、外层联动和减少动画断言,运行并确认失败。
|
||||||
|
2. 实现单一位置弹簧、短暂点按形变和外层独立布局形变,保留实时折射和稳定图标层。拖动速度和位移必须有界,父组件确认选中状态不能重启动画。
|
||||||
|
3. 增加平板/分屏布局测试,覆盖七天同屏、宽屏文字、内容限宽、横竖屏切换与操作。实现共用AdaptivePage和宽屏课表文字、Dock宽度适配,并覆盖设置和反馈。
|
||||||
|
4. 运行受影响测试、完整测试和静态分析;正式构建,检查Android模拟器手机/平板与动画关键帧,审查改动。保存版本化APK、源码、更新说明,校验签名/旧文件与登记备份。
|
||||||
|
|
||||||
|
审查重点:连续点击时位置连续;外层形变采样与实际布局一致;拖动取消无误切页;窗口缩放不重置标签/周;小窗口及大字体无溢出;减少动画无弹性。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 0.7.10 导入问题定向修复
|
||||||
|
|
||||||
|
用户取消反馈学校选择:不增加学校选择步骤,保留现有日志 school 标记。用户已授权直接实现。
|
||||||
|
|
||||||
|
- [x] 本科日志定位为第 15 条活动 room_missing。用匿名样本复现空教室整表失败,保留完整周次/节次/教师,空教室显示“教室待定”,课程备注和导入提示解释来源。教师、课程号、周次和节次仍严格校验,不跳过错误课程。
|
||||||
|
- [x] 研究生日志仅有 UnsupportedSchoolLayout,没有 HTML,不能确定具体字段。为解析各阶段增加可公开的原因码和行列号,保留失败快照;修正解析失败最终被误报为网络超时。对纯数字课程号限制及空教室补充严格格式下的兼容测试,不能声称这些就是同学的确切原因。
|
||||||
|
- [x] 红绿回归、全量 Flutter 测试、静态分析、正式构建、独立审查、源码与 APK 发布完整性/原签名核对。本次作为 0.7.10+36,保留所有既有版本和登记冻结材料,不修改线上服务器。
|
||||||
|
|
||||||
|
验证重点:空教室不丢课程;正常教室/教师不变;畸形时间仍拒绝;未知研究生格式不清空旧课表;具体错误及行列码可见但不输出课程内容;换页后不能沿用旧错误原因。
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Dock 弹性与 Android 平板适配
|
||||||
|
|
||||||
|
用户要求参考本地视频 20261028-104403.mp4:点按和拖动均有弹性落位,拖动滑块带动外层 Dock,玻璃下内容可辨,图标文字清楚;同时支持平板。沿用用户已授权直接执行的工作方式,不追加确认流程。
|
||||||
|
|
||||||
|
采用现有 Flutter 弹簧动画和实时背景折射,不引入浏览器或新玻璃组件库。相比整体放大变换,这样能通过实际布局更新玻璃区域,避免非等比变换造成采样坐标偏移;相比仅加大模糊,可保留背景颜色与轮廓。
|
||||||
|
|
||||||
|
- 滑块:点按短促伸缩和轻微弹簧落位;连续点击从当前位置、速度接续;拖动仍放大,松手回弹,取消不换页。
|
||||||
|
- 外层:按压与拖动引起小幅扩张,轻微向滑块所在侧延伸;文字和图标单独绘制,保持稳定清晰。静止仍为灰色滑块、浅白通透玻璃、蓝色图标白光。高度保持紧凑。
|
||||||
|
- 无障碍:减少动态效果时直接落位,不播放放大、抖动或外层形变。
|
||||||
|
- 平板:根据当前窗口宽度适配,兼容横竖屏和分屏。宽度不足600逻辑像素维持手机布局;宽屏课表最多1440,考试/个人页最多840,设置/反馈最多760,居中留边。课表保持七天同屏,并在宽列增大课程和时间文字。Dock 平板宽300、高58,手机264×58;不添加侧栏。
|
||||||
|
- Android 沿用可旋转/可调整大小的默认 Activity,不改变签名、权限、包名及持久化格式。不宣称已验证 iPad。
|
||||||
|
|
||||||
|
验证:点按弹性、快速反向点按、按住/拖动外层变化、取消、减弱动态效果;手机与800×1280、1280×800平板和分屏布局无溢出,七天可见,切页/周状态保留,设置/反馈可用;完整测试、分析、正式签名APK和模拟器视觉检查。
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 0.7.10:无 HTML 导入日志排查
|
||||||
|
|
||||||
|
日期:2026-10-28。版本 0.7.10+36,Android 包名 com.tpschedule.app。
|
||||||
|
|
||||||
|
## 日志结论
|
||||||
|
|
||||||
|
两份日志均标记为 TPSchedule 0.7.8+26,而非刚完成的 0.7.9。
|
||||||
|
|
||||||
|
- TPSchedule-diagnostics.txt:2026-10-25 北京时间 09:08 左右,school=undergraduate。网页完整内容恢复已应用,随后课表读取反复返回 room_missing、activityNumber=15。可确认第 15 条 TaskActivity 的教室字符串为空,被原解析器当作整表错误拒绝。
|
||||||
|
- TPSchedule-diagnostic5s.txt:2026-10-27 北京时间 09:02–09:03,school=graduate。认证后已自动访问课表,读到一个表格片段及学期 73;解析反复返回 UnsupportedSchoolLayout,最终 read_timeout。缺少 HTML 和细分原因码,无法判断究竟是字段、课程行、周次、表头还是空课表问题。不能把它归因于浏览器内核,也不能声称本轮已确定该同学的具体根因。
|
||||||
|
|
||||||
|
用户取消反馈中的学校选择:不增加选择步骤,继续依靠日志中的 school 标记。反馈界面、协议、服务器代码均不变。
|
||||||
|
|
||||||
|
## 修改
|
||||||
|
|
||||||
|
1. 本科课程的教室字符串为空时,保留全部课程、教师、周次和节次,显示“教室待定”,在课程备注中解释学校未提供教室。导入成功提示及既有导入预览会提醒用户。没有猜测实际教室,也没有跳过该课程;教师、课程标识、周次和节次异常继续拒绝整表,保留旧数据。
|
||||||
|
2. 研究生解析取消纯数字课程号的额外限制,接受首位为字母/数字、其后为字母/数字/点/下划线/连字符的课程标识;相同严格结构下,空教室也明确标为待定。空教师、无效周次、错位节次、缺失单元格和空表仍拒绝。这些是已通过匿名样本验证的兼容修复,尚不能证明命中原研究生样本。
|
||||||
|
3. 研究生拒绝原因细分为来源、大小、学期、表头、单元格、课程字段、周次、节次等常量原因码,并在适用时记录时段行号和星期列号。日志不包含课程名称、教师、教室、帐号、Cookie 或 HTML。
|
||||||
|
4. 自动读取和手动读取保留原始解析错误。读取期限或轮询次数耗尽时,若当前页面曾有具体解析错误,则显示并记录该原因;不再一概写成查找/网络超时。错误按页面代次隔离,换页后不继承旧原因;成功解析后清除旧原因。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 原始匿名复现有 4 项失败,分别为空教室、研究生代码/空教室、具体原因、错误被超时覆盖;修复后通过。
|
||||||
|
- Flutter 全量 337 项测试通过,静态分析无问题,正式 APK 构建通过;独立审查无需要修复的缺陷。
|
||||||
|
- 对比缺教室前后的全部 courseCode/teacher/weekday/period/week 集合,确认没有丢课、少周或少节。
|
||||||
|
- 本科导入界面的模拟流程验证课程入库及“部分课程教室待定”提示。
|
||||||
|
- 模拟 45 秒读取期限后仍保留 weeks_invalid;新页面不沿用旧周次错误;诊断不含课程值。
|
||||||
|
- 既有学校来源校验、学期隔离、失效页面保护、未知布局不清空旧课表及反馈功能测试保持通过。
|
||||||
|
|
||||||
|
本轮没有失败同学的 HTML,也未使用他们的帐号复测。请让同学先安装本修复版重试;研究生若仍失败,需新日志中的细分代码,最好自愿附带脱敏学校 HTML,才能继续定位实际页面差异。
|
||||||
|
|
||||||
|
## 交付与发布
|
||||||
|
|
||||||
|
TPSchedule-0.7.10.apk,同签名覆盖安装,构建号 36。原公钥、私钥、备案号和 0.5.4 的 18 项登记冻结材料保持,旧 APK/源码保持。
|
||||||
|
|
||||||
|
服务器无需更新。更新后台发布版本 0.7.10、构建号 36,下载链接使用上传后实际可访问的 HTTPS 地址;本地未执行线上发布。
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 0.7.8+32 点按放大、外溢与柔软玻璃
|
||||||
|
|
||||||
|
根据用户要求,点按像拖动一样先放大再弹跳,Dock随滑块运动,边缘滑块允许越出Dock;随后追加外壳需要更柔软的液态感。沿用用户授权直接执行的方式。
|
||||||
|
|
||||||
|
- 点按形变延长为620ms,增加宽高共同放大的脉冲,后半平滑衰减并挤压回弹;测试要求横向峰值大于静止的1.4倍,纵向大于1.25倍。点按弹簧阻尼20,连续点击延续已有尺寸与速度。
|
||||||
|
- 点按期间底座弹簧根据滑块速度改变目标,产生有先后顺序的方向跟随;脉冲结束且两轴稳定后停表。原持续拖动时钟和有限形变波保留。
|
||||||
|
- 放大时滑块可越出底座的左右、上下边缘,使用实际布局尺寸采样玻璃背景。溢出范围按真实父布局及安全区约束限制,屏幕/窄容器边缘保留8逻辑像素。
|
||||||
|
- 点按放大时光学状态渐变为较通透的玻璃,落位后恢复浅灰选中状态。柔化边缘光晕,降低硬描边和峰值白光,并稍增强外壳的呼吸形变;乳白层和轻薄高度沿用31。
|
||||||
|
|
||||||
|
先强化点按放大断言、新增底座方向跟随与窄屏外溢测试,确认旧版本失败,再实现;另新增带左侧安全留边的父布局约束测试,确认仅按整屏计算会越界,改按更窄的父约束后通过。
|
||||||
|
|
||||||
|
验证包括322项Flutter测试、静态分析、正式构建;原点按反向连续性、图标动画/释放衔接、减少动画、拖动、平板与分屏仍覆盖。检查右侧点按外溢、移动与收回的逐帧渲染预览。预览为Flutter渲染示意,本轮未在实体平板上测性能。
|
||||||
|
|
||||||
|
版本0.7.8、构建32。包名、签名和私钥保持;保留旧APK、旧源码及0.5.4登记冻结资料。服务器无需改代码;发布时填版本0.7.8、构建号32及对应APK的HTTPS下载链接。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 0.7.8+31 拖动牵拉与轻薄玻璃
|
||||||
|
|
||||||
|
用户要求拖动过程中也有持续的Q弹形变,并减薄玻璃;随后提供飞书20261028-114548.mp4,参考其中滑块、底座前缘被牵动和反向回弹的效果。本轮按已授权的直接执行方式完成。
|
||||||
|
|
||||||
|
## 改动
|
||||||
|
|
||||||
|
- 滑块以680/28的弹簧轴追随手指目标,有少量弹性滞后;释放时仍按手指最终位置选择标签,避免视觉滞后选错。重新抓取与取消继承可见速度。
|
||||||
|
- 拖动输入唤醒620ms有限形变波,移动过程中横向伸缩、纵向挤压,后续输入可续发;暂停后自然结束,静止时不无限播放。按压、松手和减少动画状态调制形变强度。
|
||||||
|
- 整条Dock随方向移动约8逻辑像素,并沿前缘额外伸展约6逻辑像素;反向时沿弹簧回收。图标和文字随底座位置移动,保持清晰,原到位动画继续。
|
||||||
|
- 乳白层由38%/24%/30%减至29%/17%/23%;静止灰色滑块减轻,外缘柔光描边2.8降至1.8、内缘0.7降至0.45;阴影和折射高光减弱,保留轻模糊与折射。高度仍58,不改变点击区域及平板布局。
|
||||||
|
|
||||||
|
## 连续拖动的关键修正
|
||||||
|
|
||||||
|
逐帧预览发现,反复对同一控制器调用animateWith会在持续输入时重启计时,可能反复画出t=0,导致跟随几乎不移动。改为单一持续Ticker同时积分两个弹簧轴,触摸只更新目标。积分子步长不超过1/120秒,帧间隔封顶0.25秒;两个轴稳定后停表,减少动画及销毁时停止。无后台常驻动画。
|
||||||
|
|
||||||
|
新增匀速连续拖动的中心实际位移断言,修正前失败、修正后通过;不是仅检查有无放大。另有牵拉滞后、前缘不对称伸展、移动中横纵形变和变薄透光层的RED→GREEN验证。
|
||||||
|
|
||||||
|
## 验证与交付
|
||||||
|
|
||||||
|
320项Flutter测试通过,静态分析无问题。保留点按弹性、快速反向点按连续性、图标悬停及松手衔接、取消归位、减少动画和平板/分屏回归。
|
||||||
|
|
||||||
|
已逐帧检查右拖、左拖、反向、释放的渲染预览。独立审查未发现新的重要缺陷;持续Ticker的积分、生命周期与降动态路径另行复核通过。预览为Flutter渲染示意,本轮未作实体平板性能测试。
|
||||||
|
|
||||||
|
APK为0.7.8、构建31,原包名、签名、密钥及备案号保留。旧APK、旧源码和0.5.4登记冻结文件校验保持。服务器无需更新代码;更新后台发布版本0.7.8、构建号31和对应HTTPS下载地址。
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# TPSchedule 0.7.8+27:乳白透光液态玻璃Dock
|
||||||
|
|
||||||
|
2026-10-24。用户指出参考图中的液态玻璃带白色材质与折射,要求优化现有Dock。此次只调整材质、着色器和发布构建号,沿用0.7.8版本名及原包名、签名、备案号。
|
||||||
|
|
||||||
|
## 视觉调整
|
||||||
|
|
||||||
|
- Dock基底由近乎透明的5%/0.8%/1.8%叠色改为24%/12%/18%的白色渐变,带柔和乳白透光层。
|
||||||
|
- 加强弧形内外亮边及方向反光,优化柔和悬浮阴影,表现边缘厚度;避免叠加的阴影被玻璃取样后使白色主体变灰。
|
||||||
|
- 折射边缘位移系数由0.20调至0.23,并加入掠射角白色反射;背景仍实时取样。保留原坐标、纹理方向和像素密度处理,不改折射定位。
|
||||||
|
- 基底轻度模糊1.5逻辑像素,滑块仍随按压减少模糊。静止灰色滑块、蓝色图标、白光晕和原放大/回弹保留。
|
||||||
|
- Dock尺寸264×58、常驻、图标文字及点击/拖动规则不变。当前页面导航、学校导入、通知与反馈服务未改。
|
||||||
|
|
||||||
|
## 验证范围
|
||||||
|
|
||||||
|
增加深色背景上的实际像素测试,要求白色材质能提亮背景且继续透出原色。调整旧透明材质的颜色预期为乳白材质,要求饱和背景至少保留一半色差;原细纹柔化、大轮廓透出、移动后折射对齐、点按不抖、放大回弹、取消拖动与减少动画测试继续执行。修改前深色背景测试红通道27,不满足乳白提亮要求;修改后通过。
|
||||||
|
|
||||||
|
着色器支持时使用实时背景折射;不支持的渲染方式沿用原轻度模糊、乳白叠色和边缘绘制,不伪称支持真实折射。
|
||||||
|
|
||||||
|
最终303项Flutter测试通过、静态分析无问题,15项材质/运动/导航测试通过,正式APK构建成功。Android16/API36模拟器使用软件渲染启动参数覆盖安装及冷启动成功,TotalTime2899ms,检查了实际首页与Dock,未见AndroidRuntime致命错误。本机默认GPU试启动时模拟器进程退出;此轮原生截图属于模糊回退路径,不能替代真机折射效果验证。支持着色器的像素测试覆盖动态取样位置、大轮廓透出和细纹柔化。独立代码审查未发现需修正的问题。
|
||||||
|
|
||||||
|
新APK包名com.tpschedule.app,versionName0.7.8、versionCode27;apksigner验证签名有效,证书MD5为78DE9119AD14727FA1C5E5E4E68145EE,与原构建26证书逐字节一致,RSA公钥与用户提供值一致。密钥与0.5.4登记冻结材料不改动。
|
||||||
|
|
||||||
|
## 发布
|
||||||
|
|
||||||
|
版本号0.7.8,构建号27;保留构建26的安装包。更新后台可使用相同版本名与更高构建号发布,链接须对应本次真实APK。反馈服务器不因此次Dock优化产生新部署步骤。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 0.7.8+30 点按Q弹、方向弹动和乳白玻璃
|
||||||
|
|
||||||
|
根据构建29后的反馈继续调整,仍沿用Flutter和原签名,版本0.7.8、构建号30。用户授权直接执行;同一轮追加的方向跟随、图标动画和乳白折射参考图一并纳入。
|
||||||
|
|
||||||
|
## 行为与材质
|
||||||
|
|
||||||
|
- 点按560ms形变:前半段横向拉伸、轻微纵向挤压,后半段横向收窄并纵向回弹。独立点按位置弹簧350/22,拖动释放仍用原360/26。连续点击继承位置、速度和未结束的形变,避免尺寸重置跳动。
|
||||||
|
- 拖动时整个Dock(底座、滑块、图标、文字)随方向弹动,使用实际布局位置而非非等比缩放,幅度约8逻辑像素,松手/取消后弹簧回中。方向由拖动速度决定,与当前标签在左还是右无关。
|
||||||
|
- 玻璃接近图标中心时触发一次440ms图标弹起与缩放;点按和拖动悬停均生效,停留不重复播放,首次启动静止。文字保持稳定。减少动画时关闭这些效果。
|
||||||
|
- 对照用户提供的20261028-111754.jpg,乳白透光层从24%/12%/18%增加至38%/24%/30%,增强弧面边缘折射高光,基础折射强度1.14。保留原轻模糊半径,灰色静止滑块、蓝色实心图标和白光继续保留。
|
||||||
|
|
||||||
|
## 验证记录
|
||||||
|
|
||||||
|
先强化点按测试,确认旧实现达不到更明显的横向伸展、落位横向收窄和纵向回弹;修改后通过。新方向跟随和图标动画测试均先在旧实现失败再通过。
|
||||||
|
|
||||||
|
318项Flutter测试通过,静态分析无问题,正式APK构建成功。动画测试覆盖快速反向点击的位置/尺寸连续、右拖/左拖整条响应、取消/松手回中、图标触发后归位、拖动悬停触发、减少动画及途中开关、平板/分屏回归。材质测试覆盖更明显的乳白透光层、背景色彩保留、细节柔化、大轮廓可辨和移动时折射坐标对齐。
|
||||||
|
|
||||||
|
独立审查发现拖动悬停时松手切换选中项会因可变key重建图标动画。先新增同帧释放前后位置/尺寸连续性测试并确认失败,再让各标签的手势及动画子树使用稳定身份,将光标定位key移至不持有动画状态的图标节点;回归通过且无命中测试警告。
|
||||||
|
|
||||||
|
更厚的乳白层必然降低背景对比,材质断言改为保留至少40%的大轮廓对比;不是不透明白色填充。图标和文字单独位于玻璃上方,不参与背景折射或模糊。
|
||||||
|
|
||||||
|
已用Flutter测试渲染器生成并逐帧检查彩色背景上的动画预览;预览为渲染示意,不是实体手机录屏。本轮未在实体平板上检查性能,原平板布局保持。无服务器功能或数据格式变更。
|
||||||
|
|
||||||
|
原APK、源代码压缩包、18项0.5.4登记冻结文件和私钥保持原样。新APK证书与29逐字节一致;仍可覆盖安装。后台发布填写版本0.7.8、构建号30及新APK对应的HTTPS下载链接。
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 0.7.8+34:灰蓝导航与乳白透射
|
||||||
|
|
||||||
|
日期:2026-10-28。依据用户提供的平板应用商店截图及之前的拖动视频,增强乳白底座、实时透射和滑块经过图标时的折射感。
|
||||||
|
|
||||||
|
## 变化
|
||||||
|
|
||||||
|
- 未选中的图标、文字改为不透明中性灰,选中项为蓝色。颜色跟随实际选中的页面;拖动经过中间项时不会把尚未切换到的页面染蓝。保留白色光晕。
|
||||||
|
- 底座白色渐变从 .29/.17/.23 调整为 .41/.25/.34,仍透出下方颜色和较大的形状。增加曲面边缘折射范围、环境色反射和白色掠射高光;保留薄边及柔软轮廓。
|
||||||
|
- 在图标行上方增加一个不接收触摸的实时光学层,与滑块共用位置和尺寸。拖动、按住和点按回弹过程中,它采样已经绘制好的图标与文字,使滑块边缘实际弯折这些内容,而不只是缩放整个图标或改变背景。
|
||||||
|
- 上层折射强度最多 .52,不再追加模糊。逐帧预览后下调了初始强度,避免小字在玻璃边缘被过度拉碎。力度随按压/点按动画渐退,静止时移除额外光学层,恢复锐利图标。
|
||||||
|
- 既有点按膨胀、拖动持续 Q 弹、方向牵拉、图标到位动画、边缘外溢、减少动态效果及手机/平板尺寸继续保留。Dock 高度仍为 58。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- Flutter 全量 329 项测试通过,静态分析无问题,正式 APK 构建通过。
|
||||||
|
- 新增测试覆盖:只有选中项为蓝色;点按动画中和拖动未提交时仍与真实页面一致;上层光学层在图标之后绘制、不遮挡交互,并在停止后移除。
|
||||||
|
- Impeller 像素测试用已绘制的蓝色日历形状作为输入,确认零模糊上层折射改变超过 20 个像素,同时保留超过 70% 的蓝色面积,证明内容确实参与透射。
|
||||||
|
- 材质像素测试继续检查深色背景下的乳白抬亮、白底不变灰、灰色静止滑块、细节柔化、大色块保留和移动时采样对齐。
|
||||||
|
- 对最新 Flutter 渲染的点按、按住、来回拖动和释放逐帧检查;安装包旁附动画与静态预览,属于渲染示意,并非真机性能测量。
|
||||||
|
- 独立审查未发现需要修复的缺陷,31 项聚焦测试通过。未新增真机或实体平板性能结论。
|
||||||
|
|
||||||
|
不支持 shader image filter 的渲染器沿用 Gaussian 降级;新增零模糊图标层在这类设备上不产生折射,实际观感较弱。
|
||||||
|
|
||||||
|
## 交付
|
||||||
|
|
||||||
|
版本 0.7.8+34,沿用包名 com.tpschedule.app、原签名/公钥/私钥和备案号。包括构建 33 的提醒缓存恢复修复及恢复前诊断,详见[提醒记录](tpschedule-0.7.8-reminder-idle.md)。vivo 几小时长待机漏提醒的根因仍需新版真机诊断确认。
|
||||||
|
|
||||||
|
保留旧版本产物及 0.5.4 软著登记冻结材料;不需要为本次变更更新服务器代码。更新后台填写版本 0.7.8、构建号 34。
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 0.7.8+29 Dock 弹性与 Android 平板适配
|
||||||
|
|
||||||
|
参考用户提供的 20261028-104403.mp4,保留浅白通透底座、灰色静止滑块、蓝色图标白光和课表/考试/我的文字。
|
||||||
|
|
||||||
|
点按改为弹簧移动,移动中短暂拉伸,落位有克制回弹。连续点按继承位置和速度,未结束的尺寸脉冲继续播放,避免重新开始导致边缘跳动。拖动滑块继续放大并变通透;底座随按压和拖动方向轻微扩张、偏移、回弹。通过调整玻璃实际布局范围实现,图标和文字保持独立,未对整个Dock做拉伸变换。沿用原折射着色器和轻模糊,不增加雾化程度。
|
||||||
|
|
||||||
|
当前窗口达到600逻辑像素时Dock宽300,高58;窄窗口保持264×58。课表区域最多1440宽,考试和我的最多840宽,设置和反馈最多760宽,居中留边。课表每列足够宽时使用更大字号,并用同样字号重新计算老师、教室的完整显示高度。仍为七天同屏和统一节次高度,课程名可省略并点开详情。横竖屏和分屏缩放保留当前标签、周次和滚动状态。
|
||||||
|
|
||||||
|
系统减少动画时直接落位,关闭形变;动画运行中开启该设置会清零尺寸脉冲,重新关闭也不会留下冻结形变。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 314项Flutter测试通过,静态分析无问题,正式APK构建成功。
|
||||||
|
- 新增/更新行为测试覆盖:点按弹性和有界回弹,快速连续反向点按(90ms、230ms,检查位置与尺寸连续),拖动外框联动、取消归位、减少动画即时/运行中切换、Dock常驻。
|
||||||
|
- 布局测试覆盖800×1280及1280×800,缩窄到480×800,七天可见、切换标签返回时周次不变、设置/反馈在1.5倍字号下无溢出并可输入。
|
||||||
|
- 旧冲突卡片测试在更大字号下出现2.84e-14的浮点边界差异,改为0.001逻辑像素容差;课程仍为上下相接,无可见重叠。
|
||||||
|
- Android16/API36测试模拟器安装正式APK成功;1920×1200和1200×1920物理尺寸、密度240分别对应1280×800和800×1280逻辑窗口。实际打开示例课表、切换页面、换周并改变屏幕方向,周次保留;恢复手机尺寸也正常。检查截图并录制点按/拖动动画,未发现Flutter或AndroidRuntime致命错误。
|
||||||
|
- 模拟器原生页面检查使用兼容软件渲染,验证布局和动画;支持着色器的Flutter测试另行通过,包含背景颜色、柔化、边缘折射和坐标对齐测试。未在实体平板或iPad上验证,不把兼容渲染截图当作实体设备折射效果的证明。
|
||||||
|
- 独立审查发现的两项动画边界问题均先用测试复现再修正:中途开关减少动画的冻结脉冲;连续点击重置脉冲/限速导致尺寸跳变。修正后全套314项测试通过。
|
||||||
|
|
||||||
|
## 交付
|
||||||
|
|
||||||
|
显示版本0.7.8,构建号29,包名com.tpschedule.app。APK、源码和截图按构建号保存于dist。签名MD5仍为78 DE 91 19 AD 14 72 7F A1 C5 E5 E4 E6 81 45 EE;证书和RSA公钥与28一致。密钥、旧版本安装包和0.5.4登记冻结资料保持原样。
|
||||||
|
|
||||||
|
这次只更新APP,不需要更新服务器。若使用更新后台推送,填写版本0.7.8、构建号29,并提供本次APK对应的HTTPS下载链接。
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# TPSchedule 0.7.8+28:通知权限排查与通道初始化
|
||||||
|
|
||||||
|
日期2026-10-27。用户反馈vivo X200 Pro(V2405A)、Android16上后台没有课前通知,先前已允许精确闹钟及后台运行。本次先收集设备状态并用正式APK验证,未凭手机品牌归因。
|
||||||
|
|
||||||
|
## 已确认的直接原因及恢复
|
||||||
|
|
||||||
|
用户0.7.8+27首份诊断返回notifications=false、permissionDenied、scheduledCount=0和pendingCount=0;exactAlarm=true,backgroundRestricted=false,batteryOptimizationsIgnored=true、powerSaveMode=false、勿扰过滤正常。此时程序因为通知未获允许而没有安排课程提醒,不能归因精确闹钟或省电限制。
|
||||||
|
|
||||||
|
用户进入APP“通知设置”检查后,第二份诊断为notifications=true、exact、scheduledCount=23及pendingCount=23。随后重测“10秒后台测试”,用户明确确认“这次收到了”。因此现有构建27已经在用户手机恢复后台通知,本次没有证据认定Android16/厂商固件或Gson/R8本身存在故障。
|
||||||
|
|
||||||
|
同签名正式构建27也在独立Android16/API36测试设备完成原生验证:dumpsys alarm显示RTC_WAKEUP精确任务、约10秒后的请求时间、ScheduledNotificationReceiver;返回桌面12秒后dumpsys notification包含id=-1002、tag=hepan-test的通知记录。立即通知id=-1001也存在,未见接收器异常。测试设备使用软件渲染启动参数,不涉及真实学校帐号。
|
||||||
|
|
||||||
|
## 本次小修补
|
||||||
|
|
||||||
|
- 通知初始化成功后就创建hepan_lessons“课前提醒”类别,再查询权限/登记任务;此前仅在首次实际发出通知时才创建,诊断和设置中可能看不到类别。
|
||||||
|
- 使用原类别ID、原重要级别和配置,Android保留用户对已有类别的选择,不自动开启用户关闭的通知。
|
||||||
|
- 手动诊断增加appNotificationsEnabled、channelExists和courseChannelEnabled,分别显示通知总开关与提醒类别。导出的诊断使用相同显式字段白名单,不含课程、帐号或认证信息。
|
||||||
|
- 权限不足的提示明确说明当前没有安排课前提醒、应检查总开关与类别后重新检查。结构化日志记录允许/应用通知关闭/类别关闭三类结果。
|
||||||
|
- 原pendingNotificationRequests读取的是插件本机缓存,单凭pendingCount不能证明系统到点派发。本次以真实系统闹钟/通知记录和用户复测作为恢复证据;登记校验提示不再称为系统保存证明。
|
||||||
|
- 调度模式、课程时间、提醒多选、原receiver和权限清单不变,未引入系统闹钟兼容模式、常驻服务或新增省电豁免请求。
|
||||||
|
|
||||||
|
## 验证与交付
|
||||||
|
|
||||||
|
新增“首次检查之前已有类别”和“总开关/类别分别显示”回归,修改前均观察到失败;修改后通过,原拒绝类别、初始化重试及后台测试隔离等测试继续执行。独立检查发现Android7没有类别却显示缺失提示的问题,追加API24回归先失败后通过,旧系统不再显示该提示。
|
||||||
|
|
||||||
|
最终306项Flutter测试通过、静态分析无问题,正式APK构建成功。独立Android16/API36测试设备上安装构建28、清空该专用测试APP的数据并重新授予测试权限,首次进入APP即查到hepan_lessons类别、importance4、默认声音及振动,未先发送通知。随后精确登记后台测试并收到通知。
|
||||||
|
|
||||||
|
另一次正式包测试登记后回桌面,以测试设备root权限向已核对包名的APP进程发SIGKILL(不执行force-stop):原进程4158退出,10秒后系统创建接收器进程4461,通知记录出现id=-1002/tag=hepan-test,未见AndroidRuntime致命错误。证明该环境不依赖Flutter进程常驻即可投递;不是对所有厂商限制的无条件保证。测试设备使用软件渲染,通知触发仍为真实AlarmManager/receiver/native notify。
|
||||||
|
|
||||||
|
apksigner验证构建28签名有效,证书与构建27逐字节一致;MD5为78DE9119AD14727FA1C5E5E4E68145EE,RSA公钥与用户提供值一致。密钥、旧APK及0.5.4登记冻结材料保持。
|
||||||
|
|
||||||
|
版本名0.7.8、构建号28,com.tpschedule.app;原证书MD5、公钥、私钥和备案号保持。旧构建27保留。未改服务器或SMTP,未访问或修改用户手机配置,手机系统设置由用户自行操作完成。
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 0.7.8+33:长时间后台提醒排查与恢复
|
||||||
|
|
||||||
|
日期:2026-10-28。问题设备为 vivo X200 Pro / Android 16。用户确认通知、精确提醒、自启动、关联启动和后台高耗电均已允许;没有重启或划掉应用,只是几小时或半天未打开后漏提醒。此前通知总开关关闭的问题已恢复,本次不能沿用那个结论。
|
||||||
|
|
||||||
|
## 已复现并修复的程序问题
|
||||||
|
|
||||||
|
提醒协调原先只比较插件中的缓存 ID 和内存中的课程指纹。如果系统 PendingIntent 已丢失而插件缓存仍在,同一个应用进程会跳过重新登记。回归测试先复现“应重新登记但没有登记”,修复后通过:相同课程、时间、提醒内容下,只要系统触发信息缺失,也会重新安排。
|
||||||
|
|
||||||
|
新增只读原生检查,使用和 flutter_local_notifications 22.3.2 一致的显式 Receiver、ID 和 immutable 标记,通过 FLAG_NO_CREATE 查询,不创建替代触发任务。检查失败不阻断原有安排流程。PendingIntent 存在本身不证明 AlarmManager 中仍有有效任务,更不能证明已投递;不存在则可作为恢复依据。
|
||||||
|
|
||||||
|
## 诊断保留
|
||||||
|
|
||||||
|
- Activity 创建时,在 Dart 自动恢复前记录插件课程缓存数量、现存/缺失触发信息数量,以及超过预计提醒时间两分钟的缓存数量。
|
||||||
|
- 暖启动/返回前台时,先检查全部缓存,再删除已过期提醒;有异常的摘要保留在当前 Activity 中,供随后“检查提醒”复制。避免恢复动作覆盖现场。摘要不跨 Activity 重建持久保存。
|
||||||
|
- “检查提醒”先记录检查前的状态,再协调和读取检查后状态。
|
||||||
|
- 增加系统待机分组、Doze 状态、最近一次进程退出原因/时间、启动前停止状态和仍显示的最近课程通知时间。API 不支持或系统拒绝读取时保留未知值。
|
||||||
|
- 复制出的报告不包含课程名称、教师、教室、提醒 ID、帐号、密码、Cookie 或原始错误正文。诊断只在用户主动复制后对外提供。
|
||||||
|
|
||||||
|
进程退出原因不能单独证明强行停止;当前待机分组也可能已因打开应用恢复为活跃。已消失的通知无法用“仍显示的最近通知时间”判断是否曾投递。过期缓存也可能来自时间设置变更等情况,不能直接认定厂商丢弃任务。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- Flutter 全量 326 项测试通过;静态分析无问题;正式 APK 构建通过。
|
||||||
|
- Android 原生 JUnit 两项通过:只读计数、去重、排除测试 ID 和空列表;不输出课程 ID。
|
||||||
|
- 新增回归覆盖:缓存和指纹相同但触发丢失时重建;暖启动过期记录在取消前检查;原生返回 ID 限定于请求范围;可选检查失败不阻断;诊断字段白名单与检查顺序。
|
||||||
|
- 正式 33 包安装至 Android 16 API 36 专用模拟器。仅向测试设备加入两条合成课程缓存,未登记对应系统任务,其中一条已过期。启动自动清理后,“检查提醒”仍显示缺失 2 条、过期 1 条,待发送 0 条,证明恢复前信息得以保留。
|
||||||
|
- 正式 33 包通过应用内“10 秒后台测试”,Home 后强制进入深度 Doze,并设置 rare 待机分组(40)。系统记录确认存在精确 RTC_WAKEUP,随后实际收到 id=-1002、tag=hepan-test 的通知。测试后恢复模拟器待机与电池状态。
|
||||||
|
- 前序正式 32 包还验证过普通进程被杀后由原生 Receiver 唤起投递,以及设备真正重启后未打开应用即恢复并投递。它们是基础原生路径的证据,不能替代 vivo 真机长待机复测。
|
||||||
|
- 独立审查未发现需要修复的缺陷;审查指出的暖启动证据缺失已补上并以回归验证。
|
||||||
|
|
||||||
|
本地验证日志位于 .tools/033-*.log、033-audit-result.xml、033-idle-*.txt;测试使用合成数据,不进入公开源码包。
|
||||||
|
|
||||||
|
## 使用与边界
|
||||||
|
|
||||||
|
同签名覆盖安装 0.7.8+33,打开一次以安排当前课程提醒。若之后几小时未打开又漏提醒,请进入“我的 → 课表设置 → 检查提醒 → 复制提醒诊断”,将报告和漏提醒的大致时间提供给开发者。可以正常打开应用,新诊断会尽量保留自动恢复前的证据。
|
||||||
|
|
||||||
|
本轮修复的是已复现的恢复判断错误,并增加取证能力。尚未在 vivo 真机复现几小时后的失效,不能声称已确定或彻底解决该手机上的根因。未添加常驻前台服务、持续后台计时器或厂商私有接口。
|
||||||
|
|
||||||
|
## 平台依据
|
||||||
|
|
||||||
|
[Android 定时提醒](https://developer.android.com/develop/background-work/services/alarms)说明系统闹钟可独立于进程生命周期运行;[Android 15 停止状态行为](https://developer.android.com/about/versions/15/behavior-changes-all)说明强行停止与 PendingIntent 取消的关系。[待机分组](https://developer.android.com/topic/performance/appstandby)及[电源资源限制](https://developer.android.com/topic/performance/power/power-details)用于解释诊断字段;不据此推断本机已经触发某项限制。
|
||||||
|
|
||||||
|
包名 com.tpschedule.app、原签名密钥/证书、公钥和备案号蜀ICP备2025169276号-5A保持。0.5.4 软著登记冻结材料和旧版本产物保持。用户的服务器无需为本次提醒修复更新代码;更新后台发布时使用版本 0.7.8、构建号 33。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# TPSchedule 0.7.9 发布记录
|
||||||
|
|
||||||
|
日期:2026-10-28。Android 版本 0.7.9,构建号 35,包名 com.tpschedule.app。
|
||||||
|
|
||||||
|
## 本次调整
|
||||||
|
|
||||||
|
点按 Dock 时更快抵达目标,减少到位后的摆动:位置弹簧刚度/阻尼由 350/20 调整为 620/36,玻璃放大和回弹周期由 620ms 收短为 420ms。页面淡入/平移从 330ms 收短为 240ms。点按仍有轻微回弹,连续反向点按保持位置和尺寸连续。
|
||||||
|
|
||||||
|
拖动弹簧、持续 Q 弹、方向牵拉、按住放大、图标到位动画、乳白材质、图标折射和灰蓝选中状态沿用上一版。保留 Android 平板适配及构建 33 的提醒恢复/诊断改进。vivo 几小时待机漏提醒尚未确认彻底解决,不将它写为已修复的发布承诺。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- Flutter 全量 331 项测试通过;静态分析无问题;正式 APK 构建通过。
|
||||||
|
- 新增速度回归:点按 160ms 时滑块已接近目标;480ms 时额外光学层退出、尺寸和位置恢复。
|
||||||
|
- 新增页面回归:240ms 加一帧以内旧页退出,重新切回保留 TextField 内部编辑状态。以旧 330ms 配置复核失败,新配置通过。
|
||||||
|
- 既有快速反向点按、边缘外溢、拖动牵拉、光学像素和减少动态效果测试通过。
|
||||||
|
- 独立审查未发现需要修复的缺陷;查看实际 Flutter 渲染的点按和拖动序列。未新增真机性能结论。
|
||||||
|
- APK 证书与上一版逐字节核对,MD5 保持 78 DE 91 19 AD 14 72 7F A1 C5 E5 E4 E6 81 45 EE,公钥模数、原私钥、备案号及 18 项登记冻结材料保持。
|
||||||
|
|
||||||
|
## 发布
|
||||||
|
|
||||||
|
安装包为 TPSchedule-0.7.9.apk,源码为 TPSchedule-0.7.9-source.zip。使用同签名覆盖安装。旧 APK 与旧源码包保持原文件。
|
||||||
|
|
||||||
|
更新后台 https://app.glowtps.cn/update 填写版本 0.7.9、构建号 35,上传本次 APK 到已有文件托管处后,将真实公开 HTTPS 下载链接填入后台。无需更新服务端代码。发布说明见安装包旁的 TPSchedule-0.7.9-更新发布说明.md。
|
||||||
|
|
||||||
|
本地已准备发布产物,未访问服务器执行上传或修改线上发布记录。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.tpschedule.app;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.function.IntPredicate;
|
||||||
|
|
||||||
|
/** Read-only counts. A surviving PendingIntent alone does not prove alarm delivery. */
|
||||||
|
public final class ReminderHandleAudit {
|
||||||
|
private ReminderHandleAudit() {}
|
||||||
|
|
||||||
|
public static Map<String, Object> inspect(List<Integer> ids, IntPredicate exists) {
|
||||||
|
LinkedHashSet<Integer> courses = new LinkedHashSet<>();
|
||||||
|
for (Integer id : ids) if (id != null && id >= 0) courses.add(id);
|
||||||
|
int present = 0;
|
||||||
|
for (int id : courses) if (exists.test(id)) present++;
|
||||||
|
Map<String, Object> counts = new LinkedHashMap<>();
|
||||||
|
counts.put("cachedCourseCount", courses.size());
|
||||||
|
counts.put("existingHandleCount", present);
|
||||||
|
counts.put("missingHandleCount", courses.size() - present);
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package com.tpschedule.app
|
|||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.app.ActivityManager
|
import android.app.ActivityManager
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.usage.UsageStatsManager
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -10,14 +12,77 @@ import android.os.PowerManager
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import io.flutter.plugin.common.BinaryMessenger
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver
|
||||||
|
import org.json.JSONArray
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
class ReminderSystemBridge(private val activity: Activity, messenger: BinaryMessenger) {
|
class ReminderSystemBridge(private val activity: Activity, messenger: BinaryMessenger) {
|
||||||
private val channel = MethodChannel(messenger, "com.tpschedule.app/reminders")
|
private val channel = MethodChannel(messenger, "com.tpschedule.app/reminders")
|
||||||
|
// Captured before Dart's startup/resume reconciliation can repair the cache.
|
||||||
|
private val startupAudit = captureAlarmAudit()
|
||||||
|
private var lastProblemAudit: Map<String, Any?>? = startupAudit.takeIf {
|
||||||
|
((it["missingHandleCount"] as? Number)?.toInt() ?: 0) > 0 ||
|
||||||
|
((it["overdueCachedCount"] as? Number)?.toInt() ?: 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasHandle(id: Int): Boolean = PendingIntent.getBroadcast(activity, id,
|
||||||
|
Intent(activity, ScheduledNotificationReceiver::class.java),
|
||||||
|
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE) != null
|
||||||
|
|
||||||
|
private fun captureAlarmAudit(): Map<String, Any?> {
|
||||||
|
val at = System.currentTimeMillis()
|
||||||
|
return try {
|
||||||
|
val raw = activity.getSharedPreferences("scheduled_notifications", Activity.MODE_PRIVATE)
|
||||||
|
.getString("scheduled_notifications", "[]") ?: "[]"
|
||||||
|
if (raw.length > 2 * 1024 * 1024) return mapOf(
|
||||||
|
"capturedAtEpochMillis" to at, "errorCode" to "cache_too_large")
|
||||||
|
val rows = JSONArray(raw)
|
||||||
|
val ids = mutableListOf<Int>()
|
||||||
|
val overdue = mutableSetOf<Int>()
|
||||||
|
for (i in 0 until minOf(rows.length(), 500)) {
|
||||||
|
val row = rows.optJSONObject(i) ?: continue
|
||||||
|
if (!row.optString("payload").startsWith("hepan:")) continue
|
||||||
|
val id = row.optInt("id", -1)
|
||||||
|
if (id < 0) continue
|
||||||
|
ids.add(id)
|
||||||
|
val fireAt = runCatching {
|
||||||
|
LocalDateTime.parse(row.optString("scheduledDateTime"))
|
||||||
|
.atZone(ZoneId.of(row.optString("timeZoneName")))
|
||||||
|
.toInstant().toEpochMilli()
|
||||||
|
}.getOrNull()
|
||||||
|
if (fireAt != null && fireAt < at - 120_000) overdue.add(id)
|
||||||
|
}
|
||||||
|
val counts = ReminderHandleAudit.inspect(ids) { id ->
|
||||||
|
hasHandle(id)
|
||||||
|
}
|
||||||
|
counts + mapOf("capturedAtEpochMillis" to at,
|
||||||
|
"overdueCachedCount" to overdue.size, "partial" to (rows.length() > 500))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
mapOf("capturedAtEpochMillis" to at, "errorCode" to "snapshot_unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
channel.setMethodCallHandler { call, result ->
|
channel.setMethodCallHandler { call, result ->
|
||||||
try {
|
try {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
|
"findMissingAlarmHandles" -> {
|
||||||
|
// Preserve overdue evidence on warm resume, before Dart removes it.
|
||||||
|
val audit = captureAlarmAudit()
|
||||||
|
val auditHasProblem = ((audit["missingHandleCount"] as? Number)?.toInt() ?: 0) > 0 ||
|
||||||
|
((audit["overdueCachedCount"] as? Number)?.toInt() ?: 0) > 0
|
||||||
|
if (auditHasProblem) lastProblemAudit = audit
|
||||||
|
val ids = (call.arguments as? List<*>)?.filterIsInstance<Int>()
|
||||||
|
?.filter { it >= 0 }?.distinct()?.take(400) ?: emptyList()
|
||||||
|
val missing = ids.filterNot { hasHandle(it) }
|
||||||
|
if (missing.isNotEmpty() && !auditHasProblem) lastProblemAudit = mapOf(
|
||||||
|
"capturedAtEpochMillis" to System.currentTimeMillis(),
|
||||||
|
"cachedCourseCount" to ids.size,
|
||||||
|
"existingHandleCount" to (ids.size - missing.size),
|
||||||
|
"missingHandleCount" to missing.size)
|
||||||
|
result.success(missing)
|
||||||
|
}
|
||||||
"getStatus" -> result.success(status())
|
"getStatus" -> result.success(status())
|
||||||
"openNotificationSettings" -> {
|
"openNotificationSettings" -> {
|
||||||
openNotificationSettings()
|
openNotificationSettings()
|
||||||
@@ -39,6 +104,12 @@ class ReminderSystemBridge(private val activity: Activity, messenger: BinaryMess
|
|||||||
val activityManager = activity.getSystemService(Activity.ACTIVITY_SERVICE) as ActivityManager
|
val activityManager = activity.getSystemService(Activity.ACTIVITY_SERVICE) as ActivityManager
|
||||||
val powerManager = activity.getSystemService(Activity.POWER_SERVICE) as PowerManager
|
val powerManager = activity.getSystemService(Activity.POWER_SERVICE) as PowerManager
|
||||||
val notificationManager = activity.getSystemService(Activity.NOTIFICATION_SERVICE) as NotificationManager
|
val notificationManager = activity.getSystemService(Activity.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val lastExit = if (Build.VERSION.SDK_INT >= 30) runCatching {
|
||||||
|
activityManager.getHistoricalProcessExitReasons(activity.packageName, 0, 1).firstOrNull()
|
||||||
|
}.getOrNull() else null
|
||||||
|
val wasStopped = if (Build.VERSION.SDK_INT >= 35) runCatching {
|
||||||
|
activityManager.getHistoricalProcessStartReasons(1).firstOrNull()?.wasForceStopped()
|
||||||
|
}.getOrNull() else null
|
||||||
return mapOf(
|
return mapOf(
|
||||||
"manufacturer" to Build.MANUFACTURER,
|
"manufacturer" to Build.MANUFACTURER,
|
||||||
"model" to Build.MODEL,
|
"model" to Build.MODEL,
|
||||||
@@ -50,6 +121,20 @@ class ReminderSystemBridge(private val activity: Activity, messenger: BinaryMess
|
|||||||
"batteryOptimizationsIgnored" to powerManager.isIgnoringBatteryOptimizations(activity.packageName),
|
"batteryOptimizationsIgnored" to powerManager.isIgnoringBatteryOptimizations(activity.packageName),
|
||||||
"powerSaveMode" to powerManager.isPowerSaveMode,
|
"powerSaveMode" to powerManager.isPowerSaveMode,
|
||||||
"interruptionFilter" to notificationManager.currentInterruptionFilter,
|
"interruptionFilter" to notificationManager.currentInterruptionFilter,
|
||||||
|
"deviceIdle" to powerManager.isDeviceIdleMode,
|
||||||
|
"standbyBucket" to if (Build.VERSION.SDK_INT >= 28) runCatching {
|
||||||
|
(activity.getSystemService(Activity.USAGE_STATS_SERVICE) as UsageStatsManager).appStandbyBucket
|
||||||
|
}.getOrNull() else null,
|
||||||
|
"startupAlarmAudit" to startupAudit,
|
||||||
|
"lastProblemAlarmAudit" to lastProblemAudit,
|
||||||
|
"lastExitReason" to lastExit?.reason,
|
||||||
|
"lastExitAtEpochMillis" to lastExit?.timestamp,
|
||||||
|
"wasForceStoppedBeforeStart" to wasStopped,
|
||||||
|
// Only still-visible notifications from this app; absence is unknown.
|
||||||
|
"lastVisibleCourseNotificationAt" to runCatching {
|
||||||
|
notificationManager.activeNotifications.filter { it.tag == "hepan-course" }
|
||||||
|
.maxOfOrNull { it.postTime }
|
||||||
|
}.getOrNull(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.tpschedule.app;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
public class ReminderHandleAuditTest {
|
||||||
|
@Test public void countsMissingHandlesWithoutLeakingIdsOrChangingTheInput() {
|
||||||
|
List<Integer> input = new ArrayList<>(Arrays.asList(11, 22, 22, -1002, null));
|
||||||
|
List<Integer> queried = new ArrayList<>();
|
||||||
|
Map<String, Object> result = ReminderHandleAudit.inspect(input, id -> {
|
||||||
|
queried.add(id); return id == 11;
|
||||||
|
});
|
||||||
|
assertEquals(2, result.get("cachedCourseCount"));
|
||||||
|
assertEquals(1, result.get("existingHandleCount"));
|
||||||
|
assertEquals(1, result.get("missingHandleCount"));
|
||||||
|
assertEquals(Arrays.asList(11, 22), queried);
|
||||||
|
assertEquals(5, input.size());
|
||||||
|
assertEquals(3, result.size());
|
||||||
|
}
|
||||||
|
@Test public void emptyScheduleDoesNotQueryTheSystem() {
|
||||||
|
Map<String, Object> result = ReminderHandleAudit.inspect(Arrays.asList(-1001, -1002), id -> {
|
||||||
|
fail("Test IDs must not be inspected as courses"); return false;
|
||||||
|
});
|
||||||
|
assertEquals(0, result.get("cachedCourseCount"));
|
||||||
|
assertEquals(0, result.get("missingHandleCount"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
const appVersion = '0.7.8';
|
const appVersion = '0.7.10';
|
||||||
const appBuildNumber = 26;
|
const appBuildNumber = 36;
|
||||||
const appFilingNumber = '蜀ICP备2025169276号-5A';
|
const appFilingNumber = '蜀ICP备2025169276号-5A';
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../features/timetable/course_details.dart';
|
|||||||
import 'app_controller.dart';
|
import 'app_controller.dart';
|
||||||
import 'app_theme.dart';
|
import 'app_theme.dart';
|
||||||
import '../widgets/glass_dock.dart';
|
import '../widgets/glass_dock.dart';
|
||||||
|
import '../widgets/adaptive_page.dart';
|
||||||
import '../widgets/tab_deck.dart';
|
import '../widgets/tab_deck.dart';
|
||||||
import '../features/exams/exams_page.dart';
|
import '../features/exams/exams_page.dart';
|
||||||
import '../features/timetable/timetable_page.dart';
|
import '../features/timetable/timetable_page.dart';
|
||||||
@@ -439,46 +440,53 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
|||||||
child: TabDeck(
|
child: TabDeck(
|
||||||
index: _tab,
|
index: _tab,
|
||||||
children: [
|
children: [
|
||||||
TimetablePage(
|
AdaptivePage(
|
||||||
controller: widget.controller,
|
maxWidth: 1440,
|
||||||
onSync: _school,
|
child: TimetablePage(
|
||||||
|
controller: widget.controller,
|
||||||
|
onSync: _school,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
ExamsPage(
|
AdaptivePage(
|
||||||
controller: widget.controller,
|
child: ExamsPage(
|
||||||
onSync: () {
|
controller: widget.controller,
|
||||||
_school(exams: true);
|
onSync: () {
|
||||||
},
|
_school(exams: true);
|
||||||
onQueryOptions:
|
},
|
||||||
_knownSchool ==
|
onQueryOptions:
|
||||||
SupportedSchool.uestcUndergraduate
|
_knownSchool ==
|
||||||
? () => _school(exams: true, chooseTerm: true)
|
SupportedSchool.uestcUndergraduate
|
||||||
: null,
|
? () => _school(exams: true, chooseTerm: true)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
ProfilePage(
|
AdaptivePage(
|
||||||
controller: widget.controller,
|
child: ProfilePage(
|
||||||
feedbackSender: widget.feedbackSender,
|
controller: widget.controller,
|
||||||
onCheckUpdates: widget.updateService == null
|
feedbackSender: widget.feedbackSender,
|
||||||
? null
|
onCheckUpdates: widget.updateService == null
|
||||||
: () => _checkUpdate(),
|
? null
|
||||||
checkingUpdates: _checkingUpdate,
|
: () => _checkUpdate(),
|
||||||
onLogin: () => _school(selectSchool: true),
|
checkingUpdates: _checkingUpdate,
|
||||||
schoolName: _knownSchool?.label,
|
onLogin: () => _school(selectSchool: true),
|
||||||
onUpdateTerm: () => _school(chooseTerm: true),
|
schoolName: _knownSchool?.label,
|
||||||
authStatus:
|
onUpdateTerm: () => _school(chooseTerm: true),
|
||||||
_knownSchool ==
|
authStatus:
|
||||||
SupportedSchool.uestcUndergraduate
|
_knownSchool ==
|
||||||
? widget.undergraduate?.statusLabel ?? '未登录'
|
SupportedSchool.uestcUndergraduate
|
||||||
: widget.school?.statusLabel ?? '未登录',
|
? widget.undergraduate?.statusLabel ?? '未登录'
|
||||||
logoutLabel:
|
: widget.school?.statusLabel ?? '未登录',
|
||||||
widget.school?.logoutFailed == true ||
|
logoutLabel:
|
||||||
widget.undergraduate?.logoutFailed == true
|
widget.school?.logoutFailed == true ||
|
||||||
? '重试退出学校登录'
|
widget.undergraduate?.logoutFailed == true
|
||||||
: '退出学校登录',
|
? '重试退出学校登录'
|
||||||
onLogout:
|
: '退出学校登录',
|
||||||
widget.school?.canLogout == true ||
|
onLogout:
|
||||||
widget.undergraduate?.canLogout == true
|
widget.school?.canLogout == true ||
|
||||||
? _logout
|
widget.undergraduate?.canLogout == true
|
||||||
: null,
|
? _logout
|
||||||
|
: null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../../app/app_theme.dart';
|
|||||||
import '../../services/diagnostic_log.dart';
|
import '../../services/diagnostic_log.dart';
|
||||||
import '../../services/school_page_diagnostic.dart';
|
import '../../services/school_page_diagnostic.dart';
|
||||||
import 'feedback_submission.dart';
|
import 'feedback_submission.dart';
|
||||||
|
import '../../widgets/adaptive_page.dart';
|
||||||
|
|
||||||
typedef FeedbackAttachmentPicker = Future<FeedbackAttachment?> Function();
|
typedef FeedbackAttachmentPicker = Future<FeedbackAttachment?> Function();
|
||||||
|
|
||||||
@@ -170,287 +171,292 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
|||||||
canPop: !_submitting,
|
canPop: !_submitting,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(title: const Text('反馈与共建')),
|
appBar: AppBar(title: const Text('反馈与共建')),
|
||||||
body: _success
|
body: AdaptivePage(
|
||||||
? _successView(context)
|
maxWidth: 760,
|
||||||
: _uncertain
|
child: _success
|
||||||
? _uncertainView()
|
? _successView(context)
|
||||||
: Form(
|
: _uncertain
|
||||||
key: _form,
|
? _uncertainView()
|
||||||
child: ListView(
|
: Form(
|
||||||
padding: const EdgeInsets.all(22),
|
key: _form,
|
||||||
children: [
|
child: ListView(
|
||||||
SegmentedButton<FeedbackKind>(
|
padding: const EdgeInsets.all(22),
|
||||||
segments: const [
|
children: [
|
||||||
ButtonSegment(
|
SegmentedButton<FeedbackKind>(
|
||||||
value: FeedbackKind.problem,
|
segments: const [
|
||||||
label: Text('反馈问题和建议'),
|
ButtonSegment(
|
||||||
),
|
value: FeedbackKind.problem,
|
||||||
ButtonSegment(
|
label: Text('反馈问题和建议'),
|
||||||
value: FeedbackKind.development,
|
|
||||||
label: Text('我要参与开发'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
selected: {_kind},
|
|
||||||
showSelectedIcon: false,
|
|
||||||
onSelectionChanged: _submitting
|
|
||||||
? null
|
|
||||||
: (value) {
|
|
||||||
setState(() {
|
|
||||||
_kind = value.single;
|
|
||||||
_pendingSubmission = null;
|
|
||||||
_error = null;
|
|
||||||
});
|
|
||||||
_form.currentState?.reset();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
SurfaceCard(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_kind == FeedbackKind.problem
|
|
||||||
? '告诉我们哪里需要改进'
|
|
||||||
: '一起让课表变得更好',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
ButtonSegment(
|
||||||
Text(
|
value: FeedbackKind.development,
|
||||||
_kind == FeedbackKind.problem
|
label: Text('我要参与开发'),
|
||||||
? '请描述遇到的问题或你的建议。联系方式和图片、视频都可以选填。'
|
|
||||||
: '请留下你的学校和联系方式,方便开发者与你联系。',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
height: 1.7,
|
|
||||||
color: ink.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
],
|
||||||
if (_kind == FeedbackKind.problem)
|
selected: {_kind},
|
||||||
TextFormField(
|
showSelectedIcon: false,
|
||||||
key: const Key('feedback-message'),
|
onSelectionChanged: _submitting
|
||||||
controller: _message,
|
? null
|
||||||
enabled: !_submitting,
|
: (value) {
|
||||||
minLines: 4,
|
setState(() {
|
||||||
maxLines: 8,
|
_kind = value.single;
|
||||||
maxLength: 5000,
|
_pendingSubmission = null;
|
||||||
decoration: const InputDecoration(
|
_error = null;
|
||||||
labelText: '问题或建议(必填)',
|
});
|
||||||
alignLabelWithHint: true,
|
_form.currentState?.reset();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
SurfaceCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_kind == FeedbackKind.problem
|
||||||
|
? '告诉我们哪里需要改进'
|
||||||
|
: '一起让课表变得更好',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
onChanged: _changed,
|
),
|
||||||
validator: (value) =>
|
const SizedBox(height: 8),
|
||||||
(value?.trim().isEmpty ?? true)
|
Text(
|
||||||
? '请填写问题或建议'
|
_kind == FeedbackKind.problem
|
||||||
: null,
|
? '请描述遇到的问题或你的建议。联系方式和图片、视频都可以选填。'
|
||||||
)
|
: '请留下你的学校和联系方式,方便开发者与你联系。',
|
||||||
else
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
height: 1.7,
|
||||||
|
color: ink.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
if (_kind == FeedbackKind.problem)
|
||||||
|
TextFormField(
|
||||||
|
key: const Key('feedback-message'),
|
||||||
|
controller: _message,
|
||||||
|
enabled: !_submitting,
|
||||||
|
minLines: 4,
|
||||||
|
maxLines: 8,
|
||||||
|
maxLength: 5000,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '问题或建议(必填)',
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
),
|
||||||
|
onChanged: _changed,
|
||||||
|
validator: (value) =>
|
||||||
|
(value?.trim().isEmpty ?? true)
|
||||||
|
? '请填写问题或建议'
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
TextFormField(
|
||||||
|
key: const Key('feedback-school'),
|
||||||
|
controller: _school,
|
||||||
|
enabled: !_submitting,
|
||||||
|
maxLength: 200,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '学校(必填)',
|
||||||
|
),
|
||||||
|
onChanged: _changed,
|
||||||
|
validator: (value) =>
|
||||||
|
(value?.trim().isEmpty ?? true)
|
||||||
|
? '请填写学校'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: const Key('feedback-school'),
|
key: const Key('feedback-contact'),
|
||||||
controller: _school,
|
controller: _contact,
|
||||||
enabled: !_submitting,
|
enabled: !_submitting,
|
||||||
maxLength: 200,
|
maxLength: 200,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: '学校(必填)',
|
labelText: _kind == FeedbackKind.problem
|
||||||
|
? '联系方式(选填)'
|
||||||
|
: '联系方式(必填)',
|
||||||
|
hintText: '微信、QQ、邮箱等',
|
||||||
),
|
),
|
||||||
onChanged: _changed,
|
onChanged: _changed,
|
||||||
validator: (value) =>
|
validator: (value) =>
|
||||||
(value?.trim().isEmpty ?? true)
|
_kind == FeedbackKind.development &&
|
||||||
? '请填写学校'
|
(value?.trim().isEmpty ?? true)
|
||||||
|
? '请填写联系方式'
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
if (_kind == FeedbackKind.problem) ...[
|
||||||
TextFormField(
|
CheckboxListTile(
|
||||||
key: const Key('feedback-contact'),
|
key: const Key('feedback-logs'),
|
||||||
controller: _contact,
|
|
||||||
enabled: !_submitting,
|
|
||||||
maxLength: 200,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: _kind == FeedbackKind.problem
|
|
||||||
? '联系方式(选填)'
|
|
||||||
: '联系方式(必填)',
|
|
||||||
hintText: '微信、QQ、邮箱等',
|
|
||||||
),
|
|
||||||
onChanged: _changed,
|
|
||||||
validator: (value) =>
|
|
||||||
_kind == FeedbackKind.development &&
|
|
||||||
(value?.trim().isEmpty ?? true)
|
|
||||||
? '请填写联系方式'
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
if (_kind == FeedbackKind.problem) ...[
|
|
||||||
CheckboxListTile(
|
|
||||||
key: const Key('feedback-logs'),
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: const Text('附带近10分钟诊断日志'),
|
|
||||||
subtitle: const Text(
|
|
||||||
'包含登录跳转、课表读取及错误状态,帮助排查问题;不包含密码、验证码或课表内容。',
|
|
||||||
),
|
|
||||||
value: _includeLogs,
|
|
||||||
onChanged: _submitting
|
|
||||||
? null
|
|
||||||
: (value) {
|
|
||||||
setState(() {
|
|
||||||
_includeLogs = value ?? false;
|
|
||||||
if (!_includeLogs) _includeHtml = false;
|
|
||||||
_pendingSubmission = null;
|
|
||||||
_error = null;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
CheckboxListTile(
|
|
||||||
key: const Key('feedback-html'),
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: const Text('附带最近一次学校导入页面HTML'),
|
|
||||||
subtitle: const Text(
|
|
||||||
'适用于本科和研究生课表、考试导入。需先勾选日志。网页可能含姓名、学号、课程、教师和考场信息,仅保留最近10分钟,单独作为邮件附件发送。',
|
|
||||||
),
|
|
||||||
value: _includeHtml,
|
|
||||||
onChanged: _submitting || !_includeLogs
|
|
||||||
? null
|
|
||||||
: (value) async {
|
|
||||||
var accepted = value ?? false;
|
|
||||||
if (accepted) {
|
|
||||||
accepted =
|
|
||||||
await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('附带网页排查资料?'),
|
|
||||||
content: const Text(
|
|
||||||
'附件可能包含你的姓名、学号、课程及考试信息,将发送给开发者用于排查导入问题。密码、验证码、Cookie和认证参数不会包含在附件中。',
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () =>
|
|
||||||
Navigator.pop(
|
|
||||||
context,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
child: const Text('取消'),
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () =>
|
|
||||||
Navigator.pop(
|
|
||||||
context,
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
child: const Text('同意附带'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
) ??
|
|
||||||
false;
|
|
||||||
}
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_includeHtml = accepted;
|
|
||||||
_pendingSubmission = null;
|
|
||||||
_error = null;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
const Text(
|
|
||||||
'图片或视频(选填)',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
'最多 ${widget.limits.maxAttachments} 个,每个 ${_mb(widget.limits.maxFileBytes)} MB,合计 ${_mb(widget.limits.maxTotalBytes)} MB',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: ink.withValues(alpha: 0.55),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
for (var i = 0; i < _attachments.length; i++)
|
|
||||||
ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: Icon(
|
title: const Text('附带近10分钟诊断日志'),
|
||||||
_attachments[i].contentType.startsWith('image/')
|
subtitle: const Text(
|
||||||
? Icons.image_outlined
|
'包含登录跳转、课表读取及错误状态,帮助排查问题;不包含密码、验证码或课表内容。',
|
||||||
: Icons.videocam_outlined,
|
|
||||||
),
|
),
|
||||||
title: Text(
|
value: _includeLogs,
|
||||||
_attachments[i].filename,
|
onChanged: _submitting
|
||||||
maxLines: 2,
|
? null
|
||||||
overflow: TextOverflow.ellipsis,
|
: (value) {
|
||||||
),
|
setState(() {
|
||||||
subtitle: Text(
|
_includeLogs = value ?? false;
|
||||||
'${(_attachments[i].size / 1024).toStringAsFixed(1)} KB',
|
if (!_includeLogs) _includeHtml = false;
|
||||||
),
|
|
||||||
trailing: IconButton(
|
|
||||||
tooltip: '移除 ${_attachments[i].filename}',
|
|
||||||
onPressed: _submitting
|
|
||||||
? null
|
|
||||||
: () => setState(() {
|
|
||||||
_attachments.removeAt(i);
|
|
||||||
_pendingSubmission = null;
|
_pendingSubmission = null;
|
||||||
_error = null;
|
_error = null;
|
||||||
}),
|
});
|
||||||
icon: const Icon(Icons.close_rounded),
|
},
|
||||||
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
key: const Key('feedback-html'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('附带最近一次学校导入页面HTML'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'适用于本科和研究生课表、考试导入。需先勾选日志。网页可能含姓名、学号、课程、教师和考场信息,仅保留最近10分钟,单独作为邮件附件发送。',
|
||||||
|
),
|
||||||
|
value: _includeHtml,
|
||||||
|
onChanged: _submitting || !_includeLogs
|
||||||
|
? null
|
||||||
|
: (value) async {
|
||||||
|
var accepted = value ?? false;
|
||||||
|
if (accepted) {
|
||||||
|
accepted =
|
||||||
|
await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('附带网页排查资料?'),
|
||||||
|
content: const Text(
|
||||||
|
'附件可能包含你的姓名、学号、课程及考试信息,将发送给开发者用于排查导入问题。密码、验证码、Cookie和认证参数不会包含在附件中。',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(
|
||||||
|
context,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(
|
||||||
|
context,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
child: const Text('同意附带'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_includeHtml = accepted;
|
||||||
|
_pendingSubmission = null;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Text(
|
||||||
|
'图片或视频(选填)',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'最多 ${widget.limits.maxAttachments} 个,每个 ${_mb(widget.limits.maxFileBytes)} MB,合计 ${_mb(widget.limits.maxTotalBytes)} MB',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: ink.withValues(alpha: 0.55),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
for (var i = 0; i < _attachments.length; i++)
|
||||||
OutlinedButton.icon(
|
ListTile(
|
||||||
key: const Key('feedback-add-media'),
|
contentPadding: EdgeInsets.zero,
|
||||||
onPressed: _submitting || _picking
|
leading: Icon(
|
||||||
? null
|
_attachments[i].contentType.startsWith(
|
||||||
: _addAttachment,
|
'image/',
|
||||||
icon: const Icon(Icons.attach_file_rounded),
|
)
|
||||||
label: Text(_picking ? '正在读取附件…' : '添加图片或视频'),
|
? Icons.image_outlined
|
||||||
),
|
: Icons.videocam_outlined,
|
||||||
],
|
),
|
||||||
],
|
title: Text(
|
||||||
),
|
_attachments[i].filename,
|
||||||
),
|
maxLines: 2,
|
||||||
const SizedBox(height: 16),
|
overflow: TextOverflow.ellipsis,
|
||||||
if (_error != null) ...[
|
),
|
||||||
Text(
|
subtitle: Text(
|
||||||
_error!,
|
'${(_attachments[i].size / 1024).toStringAsFixed(1)} KB',
|
||||||
style: TextStyle(
|
),
|
||||||
color: Theme.of(context).colorScheme.error,
|
trailing: IconButton(
|
||||||
),
|
tooltip: '移除 ${_attachments[i].filename}',
|
||||||
semanticsLabel: _error,
|
onPressed: _submitting
|
||||||
),
|
? null
|
||||||
const SizedBox(height: 14),
|
: () => setState(() {
|
||||||
],
|
_attachments.removeAt(i);
|
||||||
FilledButton(
|
_pendingSubmission = null;
|
||||||
key: const Key('feedback-submit'),
|
_error = null;
|
||||||
onPressed: _submitting || _picking ? null : _submit,
|
}),
|
||||||
child: _submitting
|
icon: const Icon(Icons.close_rounded),
|
||||||
? const Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(width: 10),
|
const SizedBox(height: 8),
|
||||||
Text('正在提交…'),
|
OutlinedButton.icon(
|
||||||
],
|
key: const Key('feedback-add-media'),
|
||||||
)
|
onPressed: _submitting || _picking
|
||||||
: const Text('提交'),
|
? null
|
||||||
),
|
: _addAttachment,
|
||||||
const SizedBox(height: 12),
|
icon: const Icon(Icons.attach_file_rounded),
|
||||||
Text(
|
label: Text(_picking ? '正在读取附件…' : '添加图片或视频'),
|
||||||
'点击提交后,仅发送你在当前类型填写的内容和选择的附件。',
|
),
|
||||||
textAlign: TextAlign.center,
|
],
|
||||||
style: TextStyle(
|
],
|
||||||
fontSize: 11,
|
),
|
||||||
height: 1.6,
|
|
||||||
color: ink.withValues(alpha: 0.5),
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
],
|
if (_error != null) ...[
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
semanticsLabel: _error,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
],
|
||||||
|
FilledButton(
|
||||||
|
key: const Key('feedback-submit'),
|
||||||
|
onPressed: _submitting || _picking ? null : _submit,
|
||||||
|
child: _submitting
|
||||||
|
? const Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
Text('正在提交…'),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: const Text('提交'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'点击提交后,仅发送你在当前类型填写的内容和选择的附件。',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
height: 1.6,
|
||||||
|
color: ink.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ class _ImportPreviewState extends State<ImportPreview> {
|
|||||||
style: const TextStyle(color: river),
|
style: const TextStyle(color: river),
|
||||||
),
|
),
|
||||||
if (draft.semesterLabel != null) Text('学期:${draft.semesterLabel}'),
|
if (draft.semesterLabel != null) Text('学期:${draft.semesterLabel}'),
|
||||||
|
if (draft.courses.any((course) => course.room == '教室待定'))
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 8),
|
||||||
|
child: Text('部分课程学校未提供教室,已标为“教室待定”;请以学校最新安排为准。'),
|
||||||
|
),
|
||||||
if (widget.controller.snapshot.courses.isNotEmpty &&
|
if (widget.controller.snapshot.courses.isNotEmpty &&
|
||||||
widget.controller.snapshot.schoolTerm != draft.schoolTerm &&
|
widget.controller.snapshot.schoolTerm != draft.schoolTerm &&
|
||||||
draft.schoolTerm != null)
|
draft.schoolTerm != null)
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ class _ReminderToolsState extends State<ReminderTools> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Future<void> _check() => _run(() async {
|
Future<void> _check() => _run(() async {
|
||||||
|
Map<String, Object?> before;
|
||||||
|
try {
|
||||||
|
before = await widget.controller.notifications.diagnose();
|
||||||
|
} catch (error) {
|
||||||
|
before = {
|
||||||
|
'errorCode': error is PlatformException
|
||||||
|
? error.code
|
||||||
|
: 'diagnosis_unavailable',
|
||||||
|
};
|
||||||
|
}
|
||||||
await widget.controller.refreshServices();
|
await widget.controller.refreshServices();
|
||||||
final diagnosis = await widget.controller.notifications.diagnose();
|
final diagnosis = await widget.controller.notifications.diagnose();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -67,8 +77,20 @@ class _ReminderToolsState extends State<ReminderTools> {
|
|||||||
'courseCount': c.snapshot.courses.length,
|
'courseCount': c.snapshot.courses.length,
|
||||||
'scheduledCount': c.reminderResult.scheduledCount,
|
'scheduledCount': c.reminderResult.scheduledCount,
|
||||||
'reminderStatus': c.reminderResult.status.name,
|
'reminderStatus': c.reminderResult.status.name,
|
||||||
|
'beforeCheck': {
|
||||||
|
for (final key in [
|
||||||
|
'notifications',
|
||||||
|
'exactAlarm',
|
||||||
|
'pendingCount',
|
||||||
|
'errorCode',
|
||||||
|
])
|
||||||
|
key: _scalar(before[key]),
|
||||||
|
},
|
||||||
for (final key in [
|
for (final key in [
|
||||||
'notifications',
|
'notifications',
|
||||||
|
'appNotificationsEnabled',
|
||||||
|
'channelExists',
|
||||||
|
'courseChannelEnabled',
|
||||||
'exactAlarm',
|
'exactAlarm',
|
||||||
'pendingCount',
|
'pendingCount',
|
||||||
'channelImportance',
|
'channelImportance',
|
||||||
@@ -88,8 +110,18 @@ class _ReminderToolsState extends State<ReminderTools> {
|
|||||||
'batteryOptimizationsIgnored',
|
'batteryOptimizationsIgnored',
|
||||||
'powerSaveMode',
|
'powerSaveMode',
|
||||||
'interruptionFilter',
|
'interruptionFilter',
|
||||||
|
'deviceIdle',
|
||||||
|
'standbyBucket',
|
||||||
|
'lastExitReason',
|
||||||
|
'lastExitAtEpochMillis',
|
||||||
|
'wasForceStoppedBeforeStart',
|
||||||
|
'lastVisibleCourseNotificationAt',
|
||||||
])
|
])
|
||||||
key: _scalar(system[key]),
|
key: _scalar(system[key]),
|
||||||
|
'startupAlarmAudit': _safeStartupAudit(system['startupAlarmAudit']),
|
||||||
|
'lastProblemAlarmAudit': _safeStartupAudit(
|
||||||
|
system['lastProblemAlarmAudit'],
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -101,6 +133,22 @@ class _ReminderToolsState extends State<ReminderTools> {
|
|||||||
Object? _scalar(Object? value) =>
|
Object? _scalar(Object? value) =>
|
||||||
value is String || value is bool || value is num ? value : null;
|
value is String || value is bool || value is num ? value : null;
|
||||||
|
|
||||||
|
Map<String, Object?>? _safeStartupAudit(Object? raw) {
|
||||||
|
if (raw is! Map) return null;
|
||||||
|
return {
|
||||||
|
for (final key in [
|
||||||
|
'capturedAtEpochMillis',
|
||||||
|
'cachedCourseCount',
|
||||||
|
'existingHandleCount',
|
||||||
|
'missingHandleCount',
|
||||||
|
'overdueCachedCount',
|
||||||
|
'partial',
|
||||||
|
'errorCode',
|
||||||
|
])
|
||||||
|
key: _scalar(raw[key]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
String _errorCode(Object? value) {
|
String _errorCode(Object? value) {
|
||||||
if (value is String && RegExp(r'^[a-zA-Z0-9_]{1,64}$').hasMatch(value)) {
|
if (value is String && RegExp(r'^[a-zA-Z0-9_]{1,64}$').hasMatch(value)) {
|
||||||
return value;
|
return value;
|
||||||
@@ -125,10 +173,31 @@ class _ReminderToolsState extends State<ReminderTools> {
|
|||||||
final rawSystem = diagnosis['system'];
|
final rawSystem = diagnosis['system'];
|
||||||
final system = rawSystem is Map ? rawSystem : const {};
|
final system = rawSystem is Map ? rawSystem : const {};
|
||||||
final pending = diagnosis['pendingCount'];
|
final pending = diagnosis['pendingCount'];
|
||||||
|
final sdk = system['sdk'];
|
||||||
|
final audit =
|
||||||
|
_safeStartupAudit(system['lastProblemAlarmAudit']) ??
|
||||||
|
_safeStartupAudit(system['startupAlarmAudit']);
|
||||||
|
final missing = audit?['missingHandleCount'];
|
||||||
|
final overdue = audit?['overdueCachedCount'];
|
||||||
|
final channelsSupported = sdk is! int || sdk >= 26;
|
||||||
return [
|
return [
|
||||||
'通知权限:${_permission(diagnosis['notifications'])}',
|
'通知权限:${_permission(diagnosis['notifications'])}',
|
||||||
|
if (diagnosis['appNotificationsEnabled'] is bool)
|
||||||
|
'系统通知总开关:${_permission(diagnosis['appNotificationsEnabled'])}',
|
||||||
|
if (channelsSupported && diagnosis['channelExists'] == true)
|
||||||
|
'课前提醒类别:${_permission(diagnosis['courseChannelEnabled'])}',
|
||||||
|
if (channelsSupported && diagnosis['channelExists'] == false)
|
||||||
|
'课前提醒类别尚未建立,请重新打开应用后检查。',
|
||||||
'精确提醒:${_permission(diagnosis['exactAlarm'])}',
|
'精确提醒:${_permission(diagnosis['exactAlarm'])}',
|
||||||
if (pending is int) '待发送提醒:$pending 条',
|
if (pending is int) '待发送提醒:$pending 条',
|
||||||
|
if (missing is num && missing > 0)
|
||||||
|
'发现 $missing 条提醒的系统触发信息缺失,已保留恢复前的诊断记录。',
|
||||||
|
if (overdue is num && overdue > 0) '发现 $overdue 条缓存提醒已超过预计时间,请复制诊断以便排查。',
|
||||||
|
if (system['standbyBucket'] is num &&
|
||||||
|
(system['standbyBucket'] as num) >= 40)
|
||||||
|
'系统待机限制:当前应用处于低活跃或受限分组,可能延后提醒。',
|
||||||
|
if (system['wasForceStoppedBeforeStart'] == true)
|
||||||
|
'系统记录本次启动前应用曾处于停止状态,先前的定时触发可能已被系统取消。',
|
||||||
if (diagnosis['notifications'] == false) '系统通知已关闭,请在通知设置中开启。',
|
if (diagnosis['notifications'] == false) '系统通知已关闭,请在通知设置中开启。',
|
||||||
if (diagnosis['channelImportance'] == 0) '提醒通道已关闭,请在通知设置中开启。',
|
if (diagnosis['channelImportance'] == 0) '提醒通道已关闭,请在通知设置中开启。',
|
||||||
if (diagnosis['channelSound'] == false) '提醒通道处于静音,请在通知设置中检查声音。',
|
if (diagnosis['channelSound'] == false) '提醒通道处于静音,请在通知设置中检查声音。',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../domain/app_settings.dart';
|
|||||||
import '../../domain/semester.dart';
|
import '../../domain/semester.dart';
|
||||||
import '../../services/notification_service.dart';
|
import '../../services/notification_service.dart';
|
||||||
import 'reminder_tools.dart';
|
import 'reminder_tools.dart';
|
||||||
|
import '../../widgets/adaptive_page.dart';
|
||||||
|
|
||||||
class SettingsPage extends StatefulWidget {
|
class SettingsPage extends StatefulWidget {
|
||||||
const SettingsPage({
|
const SettingsPage({
|
||||||
@@ -146,362 +147,378 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
style: TextStyle(fontSize: 21, fontWeight: FontWeight.w700),
|
style: TextStyle(fontSize: 21, fontWeight: FontWeight.w700),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: AdaptivePage(
|
||||||
controller: _scroll,
|
maxWidth: 760,
|
||||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 40),
|
child: ListView(
|
||||||
children: [
|
controller: _scroll,
|
||||||
if (c.isDemo)
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 40),
|
||||||
const Padding(
|
children: [
|
||||||
padding: EdgeInsets.only(bottom: 18),
|
if (c.isDemo)
|
||||||
child: Text(
|
const Padding(
|
||||||
'示例模式中,提醒与日历功能不会应用到示例课程。',
|
padding: EdgeInsets.only(bottom: 18),
|
||||||
style: TextStyle(fontSize: 12, color: river),
|
child: Text(
|
||||||
),
|
'示例模式中,提醒与日历功能不会应用到示例课程。',
|
||||||
),
|
style: TextStyle(fontSize: 12, color: river),
|
||||||
_heading('课表配色'),
|
|
||||||
SurfaceCard(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Wrap(
|
|
||||||
spacing: 10,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
|
||||||
ChoiceChip(
|
|
||||||
label: const Text('多彩'),
|
|
||||||
selected: s.colorMode == ColorMode.multicolor,
|
|
||||||
onSelected: (_) => c.updateSettings(
|
|
||||||
(current) =>
|
|
||||||
current.copyWith(colorMode: ColorMode.multicolor),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ChoiceChip(
|
|
||||||
label: const Text('单色'),
|
|
||||||
selected: s.colorMode == ColorMode.single,
|
|
||||||
onSelected: (_) => c.updateSettings(
|
|
||||||
(current) =>
|
|
||||||
current.copyWith(colorMode: ColorMode.single),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 15),
|
),
|
||||||
Wrap(
|
_heading('课表配色'),
|
||||||
spacing: 14,
|
SurfaceCard(
|
||||||
runSpacing: 12,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
for (final color in [
|
children: [
|
||||||
0xFF348F91,
|
Wrap(
|
||||||
0xFF6E83B1,
|
spacing: 10,
|
||||||
0xFFB18A6E,
|
runSpacing: 8,
|
||||||
0xFF8C789E,
|
children: [
|
||||||
])
|
ChoiceChip(
|
||||||
Semantics(
|
label: const Text('多彩'),
|
||||||
label: '选择课表颜色',
|
selected: s.colorMode == ColorMode.multicolor,
|
||||||
button: true,
|
onSelected: (_) => c.updateSettings(
|
||||||
child: InkWell(
|
(current) => current.copyWith(
|
||||||
onTap: () => c.updateSettings(
|
colorMode: ColorMode.multicolor,
|
||||||
(current) => current.copyWith(
|
|
||||||
singleColorArgb: color,
|
|
||||||
colorMode: ColorMode.single,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(30),
|
),
|
||||||
child: Container(
|
),
|
||||||
width: 36,
|
ChoiceChip(
|
||||||
height: 36,
|
label: const Text('单色'),
|
||||||
decoration: BoxDecoration(
|
selected: s.colorMode == ColorMode.single,
|
||||||
color: Color(color),
|
onSelected: (_) => c.updateSettings(
|
||||||
shape: BoxShape.circle,
|
(current) =>
|
||||||
border: Border.all(
|
current.copyWith(colorMode: ColorMode.single),
|
||||||
color: s.singleColorArgb == color
|
),
|
||||||
? ink
|
),
|
||||||
: Colors.transparent,
|
],
|
||||||
width: 2,
|
),
|
||||||
|
const SizedBox(height: 15),
|
||||||
|
Wrap(
|
||||||
|
spacing: 14,
|
||||||
|
runSpacing: 12,
|
||||||
|
children: [
|
||||||
|
for (final color in [
|
||||||
|
0xFF348F91,
|
||||||
|
0xFF6E83B1,
|
||||||
|
0xFFB18A6E,
|
||||||
|
0xFF8C789E,
|
||||||
|
])
|
||||||
|
Semantics(
|
||||||
|
label: '选择课表颜色',
|
||||||
|
button: true,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => c.updateSettings(
|
||||||
|
(current) => current.copyWith(
|
||||||
|
singleColorArgb: color,
|
||||||
|
colorMode: ColorMode.single,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: s.singleColorArgb == color
|
borderRadius: BorderRadius.circular(30),
|
||||||
? const Icon(
|
child: Container(
|
||||||
Icons.check_rounded,
|
width: 36,
|
||||||
color: Colors.white,
|
height: 36,
|
||||||
size: 20,
|
decoration: BoxDecoration(
|
||||||
)
|
color: Color(color),
|
||||||
: null,
|
shape: BoxShape.circle,
|
||||||
),
|
border: Border.all(
|
||||||
),
|
color: s.singleColorArgb == color
|
||||||
),
|
? ink
|
||||||
],
|
: Colors.transparent,
|
||||||
),
|
width: 2,
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
child: s.singleColorArgb == color
|
||||||
const SizedBox(height: 22),
|
? const Icon(
|
||||||
_heading('课程条长度'),
|
Icons.check_rounded,
|
||||||
SurfaceCard(
|
color: Colors.white,
|
||||||
child: Column(
|
size: 20,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
)
|
||||||
children: [
|
: null,
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'${((_courseHeightDraft ?? s.courseHeightScale) * 100).round()}%',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: river,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: _savingHeight
|
|
||||||
? null
|
|
||||||
: () => _saveCourseHeight(
|
|
||||||
AppSettings.defaultCourseHeightScale,
|
|
||||||
),
|
),
|
||||||
child: const Text('恢复默认'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Slider(
|
|
||||||
key: const Key('course-height-slider'),
|
|
||||||
value: _courseHeightDraft ?? s.courseHeightScale,
|
|
||||||
min: AppSettings.minCourseHeightScale,
|
|
||||||
max: AppSettings.maxCourseHeightScale,
|
|
||||||
divisions: 24,
|
|
||||||
label:
|
|
||||||
'${((_courseHeightDraft ?? s.courseHeightScale) * 100).round()}%',
|
|
||||||
semanticFormatterCallback: (value) =>
|
|
||||||
'${(value * 100).round()}%',
|
|
||||||
onChanged: _savingHeight
|
|
||||||
? null
|
|
||||||
: (value) => setState(() => _courseHeightDraft = value),
|
|
||||||
onChangeEnd: _saveCourseHeight,
|
|
||||||
),
|
|
||||||
const Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text('较短', style: TextStyle(fontSize: 11)),
|
|
||||||
Text('较长', style: TextStyle(fontSize: 11)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(
|
|
||||||
'统一调整课程条长度,老师和教室始终完整显示。',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
height: 1.6,
|
|
||||||
color: ink.withValues(alpha: 0.55),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 22),
|
|
||||||
_heading('课前提醒'),
|
|
||||||
SurfaceCard(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
SwitchListTile.adaptive(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: const Text('提醒我去上课', style: TextStyle(fontSize: 15)),
|
|
||||||
value: s.remindersEnabled,
|
|
||||||
onChanged: c.isDemo
|
|
||||||
? null
|
|
||||||
: (v) async {
|
|
||||||
if (v) {
|
|
||||||
await c.enableRemindersFromUserAction();
|
|
||||||
} else {
|
|
||||||
await c.updateSettings(
|
|
||||||
(current) =>
|
|
||||||
current.copyWith(remindersEnabled: false),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 5),
|
|
||||||
Wrap(
|
|
||||||
spacing: 6,
|
|
||||||
runSpacing: 7,
|
|
||||||
children: [
|
|
||||||
for (final minutes in AppSettings.allowedReminderMinutes)
|
|
||||||
FilterChip(
|
|
||||||
label: Text('$minutes 分钟'),
|
|
||||||
selected: s.reminderOffsets.contains(minutes),
|
|
||||||
onSelected: c.isDemo
|
|
||||||
? null
|
|
||||||
: (selected) {
|
|
||||||
if (!selected &&
|
|
||||||
s.reminderOffsets.length == 1) {
|
|
||||||
_notice('请至少保留一个提醒时间');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
c.updateSettings(
|
|
||||||
(current) => current.copyWith(
|
|
||||||
reminderOffsets: [
|
|
||||||
for (final value
|
|
||||||
in current.reminderOffsets)
|
|
||||||
if (value != minutes) value,
|
|
||||||
if (selected) minutes,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(
|
|
||||||
c.reminderResult.message.isNotEmpty
|
|
||||||
? c.reminderResult.message
|
|
||||||
: c.reminderResult.status ==
|
|
||||||
ReminderStatus.permissionDenied
|
|
||||||
? '尚未允许通知权限,点击下方按钮开启。'
|
|
||||||
: '可多选提前时间,连续课程按每个选中的时间提醒。',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
height: 1.6,
|
|
||||||
color: ink.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!c.isDemo && s.remindersEnabled)
|
|
||||||
TextButton(
|
|
||||||
onPressed: c.enableRemindersFromUserAction,
|
|
||||||
child: const Text('允许课前提醒'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 22),
|
|
||||||
if (!c.isDemo) ReminderTools(controller: c),
|
|
||||||
if (!c.isDemo) const SizedBox(height: 22),
|
|
||||||
_heading('手机日历'),
|
|
||||||
SurfaceCard(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
SwitchListTile.adaptive(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: const Text('导入手机日历', style: TextStyle(fontSize: 15)),
|
|
||||||
subtitle: const Text(
|
|
||||||
'按实际上课周次同步,不重复提醒',
|
|
||||||
style: TextStyle(fontSize: 11),
|
|
||||||
),
|
|
||||||
value: s.calendarEnabled,
|
|
||||||
onChanged: c.isDemo ? null : _calendar,
|
|
||||||
),
|
|
||||||
if (c.calendarSyncError != null)
|
|
||||||
Text(
|
|
||||||
c.calendarSyncError!,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Color(0xFFB45D54),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (c.snapshot.calendarCleanupPending)
|
|
||||||
TextButton(
|
|
||||||
onPressed: c.retryCalendarCleanup,
|
|
||||||
child: const Text('重试清理课程日程'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 22),
|
|
||||||
_heading('学期安排'),
|
|
||||||
SurfaceCard(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ListTile(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: const Text('学期开始日', style: TextStyle(fontSize: 15)),
|
|
||||||
subtitle: Text(
|
|
||||||
_start == null
|
|
||||||
? '请选择学校实际开学日期'
|
|
||||||
: '${_start!.year} 年 ${_start!.month} 月 ${_start!.day} 日',
|
|
||||||
style: const TextStyle(fontSize: 12),
|
|
||||||
),
|
|
||||||
trailing: const Icon(
|
|
||||||
Icons.date_range_outlined,
|
|
||||||
color: river,
|
|
||||||
),
|
|
||||||
onTap: () async {
|
|
||||||
final date = await showDatePicker(
|
|
||||||
context: context,
|
|
||||||
initialDate: _start ?? c.clock(),
|
|
||||||
firstDate: DateTime(2020),
|
|
||||||
lastDate: DateTime(2040),
|
|
||||||
);
|
|
||||||
if (date != null && mounted) {
|
|
||||||
setState(() => _start = date);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (_dateError != null)
|
|
||||||
Text(
|
|
||||||
_dateError!,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Color(0xFFB45D54),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextField(
|
|
||||||
key: const Key('semester-weeks'),
|
|
||||||
controller: _weeks,
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
inputFormatters: [
|
|
||||||
FilteringTextInputFormatter.digitsOnly,
|
|
||||||
LengthLimitingTextInputFormatter(2),
|
|
||||||
],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: '学期总周数',
|
|
||||||
hintText: '1–30',
|
|
||||||
errorText: _weeksError,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: () async {
|
|
||||||
final weeks = int.tryParse(_weeks.text);
|
|
||||||
setState(() {
|
|
||||||
_weeksError = weeks == null || weeks < 1 || weeks > 30
|
|
||||||
? '周数应为 1–30'
|
|
||||||
: null;
|
|
||||||
_dateError = _start == null ? '请先选择学期开始日' : null;
|
|
||||||
});
|
|
||||||
if (_weeksError != null || _dateError != null) return;
|
|
||||||
await c.updateSettings(
|
|
||||||
(current) => current.copyWith(
|
|
||||||
semester: Semester(
|
|
||||||
startDate: _start!,
|
|
||||||
totalWeeks: weeks!,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_heading('课程条长度'),
|
||||||
|
SurfaceCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${((_courseHeightDraft ?? s.courseHeightScale) * 100).round()}%',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: river,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _savingHeight
|
||||||
|
? null
|
||||||
|
: () => _saveCourseHeight(
|
||||||
|
AppSettings.defaultCourseHeightScale,
|
||||||
|
),
|
||||||
|
child: const Text('恢复默认'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Slider(
|
||||||
|
key: const Key('course-height-slider'),
|
||||||
|
value: _courseHeightDraft ?? s.courseHeightScale,
|
||||||
|
min: AppSettings.minCourseHeightScale,
|
||||||
|
max: AppSettings.maxCourseHeightScale,
|
||||||
|
divisions: 24,
|
||||||
|
label:
|
||||||
|
'${((_courseHeightDraft ?? s.courseHeightScale) * 100).round()}%',
|
||||||
|
semanticFormatterCallback: (value) =>
|
||||||
|
'${(value * 100).round()}%',
|
||||||
|
onChanged: _savingHeight
|
||||||
|
? null
|
||||||
|
: (value) =>
|
||||||
|
setState(() => _courseHeightDraft = value),
|
||||||
|
onChangeEnd: _saveCourseHeight,
|
||||||
|
),
|
||||||
|
const Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('较短', style: TextStyle(fontSize: 11)),
|
||||||
|
Text('较长', style: TextStyle(fontSize: 11)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
'统一调整课程条长度,老师和教室始终完整显示。',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
height: 1.6,
|
||||||
|
color: ink.withValues(alpha: 0.55),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_heading('课前提醒'),
|
||||||
|
SurfaceCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SwitchListTile.adaptive(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text(
|
||||||
|
'提醒我去上课',
|
||||||
|
style: TextStyle(fontSize: 15),
|
||||||
|
),
|
||||||
|
value: s.remindersEnabled,
|
||||||
|
onChanged: c.isDemo
|
||||||
|
? null
|
||||||
|
: (v) async {
|
||||||
|
if (v) {
|
||||||
|
await c.enableRemindersFromUserAction();
|
||||||
|
} else {
|
||||||
|
await c.updateSettings(
|
||||||
|
(current) =>
|
||||||
|
current.copyWith(remindersEnabled: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 5),
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 7,
|
||||||
|
children: [
|
||||||
|
for (final minutes
|
||||||
|
in AppSettings.allowedReminderMinutes)
|
||||||
|
FilterChip(
|
||||||
|
label: Text('$minutes 分钟'),
|
||||||
|
selected: s.reminderOffsets.contains(minutes),
|
||||||
|
onSelected: c.isDemo
|
||||||
|
? null
|
||||||
|
: (selected) {
|
||||||
|
if (!selected &&
|
||||||
|
s.reminderOffsets.length == 1) {
|
||||||
|
_notice('请至少保留一个提醒时间');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
c.updateSettings(
|
||||||
|
(current) => current.copyWith(
|
||||||
|
reminderOffsets: [
|
||||||
|
for (final value
|
||||||
|
in current.reminderOffsets)
|
||||||
|
if (value != minutes) value,
|
||||||
|
if (selected) minutes,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
c.reminderResult.message.isNotEmpty
|
||||||
|
? c.reminderResult.message
|
||||||
|
: c.reminderResult.status ==
|
||||||
|
ReminderStatus.permissionDenied
|
||||||
|
? '尚未允许通知权限,点击下方按钮开启。'
|
||||||
|
: '可多选提前时间,连续课程按每个选中的时间提醒。',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
height: 1.6,
|
||||||
|
color: ink.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!c.isDemo && s.remindersEnabled)
|
||||||
|
TextButton(
|
||||||
|
onPressed: c.enableRemindersFromUserAction,
|
||||||
|
child: const Text('允许课前提醒'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
if (!c.isDemo) ReminderTools(controller: c),
|
||||||
|
if (!c.isDemo) const SizedBox(height: 22),
|
||||||
|
_heading('手机日历'),
|
||||||
|
SurfaceCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SwitchListTile.adaptive(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text(
|
||||||
|
'导入手机日历',
|
||||||
|
style: TextStyle(fontSize: 15),
|
||||||
|
),
|
||||||
|
subtitle: const Text(
|
||||||
|
'按实际上课周次同步,不重复提醒',
|
||||||
|
style: TextStyle(fontSize: 11),
|
||||||
|
),
|
||||||
|
value: s.calendarEnabled,
|
||||||
|
onChanged: c.isDemo ? null : _calendar,
|
||||||
|
),
|
||||||
|
if (c.calendarSyncError != null)
|
||||||
|
Text(
|
||||||
|
c.calendarSyncError!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Color(0xFFB45D54),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (c.snapshot.calendarCleanupPending)
|
||||||
|
TextButton(
|
||||||
|
onPressed: c.retryCalendarCleanup,
|
||||||
|
child: const Text('重试清理课程日程'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_heading('学期安排'),
|
||||||
|
SurfaceCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text(
|
||||||
|
'学期开始日',
|
||||||
|
style: TextStyle(fontSize: 15),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
_start == null
|
||||||
|
? '请选择学校实际开学日期'
|
||||||
|
: '${_start!.year} 年 ${_start!.month} 月 ${_start!.day} 日',
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: const Icon(
|
||||||
|
Icons.date_range_outlined,
|
||||||
|
color: river,
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
final date = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _start ?? c.clock(),
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime(2040),
|
||||||
);
|
);
|
||||||
if (mounted) {
|
if (date != null && mounted) {
|
||||||
_notice(c.lastError ?? '学期设置已保存');
|
setState(() => _start = date);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('保存学期设置'),
|
|
||||||
),
|
),
|
||||||
),
|
if (_dateError != null)
|
||||||
const SizedBox(height: 12),
|
Text(
|
||||||
Text(
|
_dateError!,
|
||||||
'开始日所在的周一至周日为第 1 周。开始日前不会生成课程。',
|
style: const TextStyle(
|
||||||
style: TextStyle(
|
fontSize: 12,
|
||||||
fontSize: 11,
|
color: Color(0xFFB45D54),
|
||||||
height: 1.6,
|
),
|
||||||
color: ink.withValues(alpha: 0.45),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
key: const Key('semester-weeks'),
|
||||||
|
controller: _weeks,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(2),
|
||||||
|
],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: '学期总周数',
|
||||||
|
hintText: '1–30',
|
||||||
|
errorText: _weeksError,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
],
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final weeks = int.tryParse(_weeks.text);
|
||||||
|
setState(() {
|
||||||
|
_weeksError =
|
||||||
|
weeks == null || weeks < 1 || weeks > 30
|
||||||
|
? '周数应为 1–30'
|
||||||
|
: null;
|
||||||
|
_dateError = _start == null ? '请先选择学期开始日' : null;
|
||||||
|
});
|
||||||
|
if (_weeksError != null || _dateError != null) return;
|
||||||
|
await c.updateSettings(
|
||||||
|
(current) => current.copyWith(
|
||||||
|
semester: Semester(
|
||||||
|
startDate: _start!,
|
||||||
|
totalWeeks: weeks!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
_notice(c.lastError ?? '学期设置已保存');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('保存学期设置'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'开始日所在的周一至周日为第 1 周。开始日前不会生成课程。',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
height: 1.6,
|
||||||
|
color: ink.withValues(alpha: 0.45),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,8 +9,32 @@ import '../../domain/school_term.dart';
|
|||||||
import 'school_navigation_policy.dart';
|
import 'school_navigation_policy.dart';
|
||||||
|
|
||||||
class UnsupportedSchoolLayout implements Exception {
|
class UnsupportedSchoolLayout implements Exception {
|
||||||
|
const UnsupportedSchoolLayout({
|
||||||
|
this.code = 'layout_unknown',
|
||||||
|
this.row,
|
||||||
|
this.column,
|
||||||
|
});
|
||||||
|
final String code;
|
||||||
|
final int? row, column;
|
||||||
|
String get description => switch (code) {
|
||||||
|
'origin_invalid' => '页面来源不符合学校课表要求',
|
||||||
|
'payload_too_large' => '课表页面超过读取大小限制',
|
||||||
|
'table_missing' => '未找到课表表格',
|
||||||
|
'semester_missing' => '课表学期信息缺失',
|
||||||
|
'row_count_invalid' => '课表时段行数不符合预期',
|
||||||
|
'weekday_header_invalid' => '星期表头格式不符合预期',
|
||||||
|
'row_cell_count_invalid' => '课表时段单元格不完整',
|
||||||
|
'period_header_invalid' => '课表节次表头无法识别',
|
||||||
|
'cell_id_invalid' => '课程单元格的星期或节次标识异常',
|
||||||
|
'course_record_invalid' => '课程名称、教师或课程字段格式无法识别',
|
||||||
|
'period_mismatch' => '课程节次与所在时段不一致',
|
||||||
|
'weeks_invalid' => '上课周次格式无法识别',
|
||||||
|
'unsafe_markup' => '课程单元格包含不支持的网页内容',
|
||||||
|
'empty_timetable' => '课表尚无可识别的课程安排',
|
||||||
|
_ => '学校课表格式暂不支持',
|
||||||
|
};
|
||||||
@override
|
@override
|
||||||
String toString() => '未识别到完整的学校课表,当前课表保持不变。请在课表查询页面保存 HTML 后重试。';
|
String toString() => '$description($code),当前课表保持不变。请在反馈中附带学校HTML和日志。';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads the supplied graduate-system table as data; never executes its scripts.
|
/// Reads the supplied graduate-system table as data; never executes its scripts.
|
||||||
@@ -22,28 +46,42 @@ class SchoolPageAdapter {
|
|||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
if (!SchoolNavigationPolicy.isTimetable(origin) ||
|
if (!SchoolNavigationPolicy.isTimetable(origin) ||
|
||||||
source == CourseSource.demo ||
|
source == CourseSource.demo) {
|
||||||
utf8.encode(content).length > 2 * 1024 * 1024) {
|
throw const UnsupportedSchoolLayout(code: 'origin_invalid');
|
||||||
throw UnsupportedSchoolLayout();
|
}
|
||||||
|
if (utf8.encode(content).length > 2 * 1024 * 1024) {
|
||||||
|
throw const UnsupportedSchoolLayout(code: 'payload_too_large');
|
||||||
}
|
}
|
||||||
final document = html.parse(content);
|
final document = html.parse(content);
|
||||||
final table = document.querySelector('#tbl');
|
final table = document.querySelector('#tbl');
|
||||||
final selected = document.querySelector('#drpxq option[selected]');
|
final selected = document.querySelector('#drpxq option[selected]');
|
||||||
final term = selected?.attributes['value'];
|
final term = selected?.attributes['value'];
|
||||||
if (table == null ||
|
if (table == null) {
|
||||||
term == null ||
|
throw const UnsupportedSchoolLayout(code: 'table_missing');
|
||||||
|
}
|
||||||
|
if (term == null ||
|
||||||
!RegExp(r'^\d+$').hasMatch(term) ||
|
!RegExp(r'^\d+$').hasMatch(term) ||
|
||||||
selected!.text.trim().isEmpty) {
|
selected!.text.trim().isEmpty) {
|
||||||
throw UnsupportedSchoolLayout();
|
throw const UnsupportedSchoolLayout(code: 'semester_missing');
|
||||||
}
|
}
|
||||||
final rows = table.querySelectorAll('tr');
|
final rows = table.querySelectorAll('tr');
|
||||||
const weekdays = ['节次', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日'];
|
const weekdays = ['节次', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日'];
|
||||||
if (rows.length != 6 || rows.first.children.length != 8) {
|
if (rows.length != 6) {
|
||||||
throw UnsupportedSchoolLayout();
|
throw const UnsupportedSchoolLayout(code: 'row_count_invalid');
|
||||||
|
}
|
||||||
|
if (rows.first.children.length != 8) {
|
||||||
|
throw const UnsupportedSchoolLayout(
|
||||||
|
code: 'weekday_header_invalid',
|
||||||
|
row: 0,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (var i = 0; i < 8; i++) {
|
for (var i = 0; i < 8; i++) {
|
||||||
if (rows.first.children[i].text.trim() != weekdays[i]) {
|
if (rows.first.children[i].text.trim() != weekdays[i]) {
|
||||||
throw UnsupportedSchoolLayout();
|
throw UnsupportedSchoolLayout(
|
||||||
|
code: 'weekday_header_invalid',
|
||||||
|
row: 0,
|
||||||
|
column: i,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final courses = <String, Course>{};
|
final courses = <String, Course>{};
|
||||||
@@ -52,28 +90,79 @@ class SchoolPageAdapter {
|
|||||||
for (var row = 0; row < starts.length; row++) {
|
for (var row = 0; row < starts.length; row++) {
|
||||||
final first = starts[row], last = first == 9 ? 11 : first + 1;
|
final first = starts[row], last = first == 9 ? 11 : first + 1;
|
||||||
final cells = rows[row + 1].children;
|
final cells = rows[row + 1].children;
|
||||||
if (cells.length != 8 ||
|
if (cells.length != 8) {
|
||||||
cells.first.id != 't0$first' ||
|
throw UnsupportedSchoolLayout(
|
||||||
|
code: 'row_cell_count_invalid',
|
||||||
|
row: row + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cells.first.id != 't0$first' ||
|
||||||
cells.first.text.trim() != '$first-$last节') {
|
cells.first.text.trim() != '$first-$last节') {
|
||||||
throw UnsupportedSchoolLayout();
|
throw UnsupportedSchoolLayout(
|
||||||
|
code: 'period_header_invalid',
|
||||||
|
row: row + 1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for (var day = 1; day <= 7; day++) {
|
for (var day = 1; day <= 7; day++) {
|
||||||
final cell = cells[day];
|
final cell = cells[day];
|
||||||
if (cell.id != 't$day$first') throw UnsupportedSchoolLayout();
|
if (cell.id != 't$day$first') {
|
||||||
for (final line in _lines(cell)) {
|
throw UnsupportedSchoolLayout(
|
||||||
|
code: 'cell_id_invalid',
|
||||||
|
row: row + 1,
|
||||||
|
column: day,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final List<String> lines;
|
||||||
|
try {
|
||||||
|
lines = _lines(cell);
|
||||||
|
} on UnsupportedSchoolLayout catch (error) {
|
||||||
|
throw UnsupportedSchoolLayout(
|
||||||
|
code: error.code,
|
||||||
|
row: row + 1,
|
||||||
|
column: day,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (final line in lines) {
|
||||||
final record = RegExp(
|
final record = RegExp(
|
||||||
r'^(\d+)/(.+?)/(\d+(?:\.\d+)?)/(\d+(?:\.\d+)?)/([^/]+)/([^/]+)周/(\d+)[~~-](\d+)节/(.+)$',
|
r'^([A-Za-z0-9][A-Za-z0-9_.-]*)\s*/(.+?)/\s*(\d+(?:\.\d+)?)\s*/\s*(\d+(?:\.\d+)?)\s*/([^/]+)/([^/]+)周/(\d+)[~~-](\d+)节/(.*)$',
|
||||||
).firstMatch(line);
|
).firstMatch(line);
|
||||||
if (record == null ||
|
if (record == null) {
|
||||||
int.parse(record[7]!) != first ||
|
throw UnsupportedSchoolLayout(
|
||||||
|
code: 'course_record_invalid',
|
||||||
|
row: row + 1,
|
||||||
|
column: day,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (int.parse(record[7]!) != first ||
|
||||||
int.parse(record[8]!) != last) {
|
int.parse(record[8]!) != last) {
|
||||||
throw UnsupportedSchoolLayout();
|
throw UnsupportedSchoolLayout(
|
||||||
|
code: 'period_mismatch',
|
||||||
|
row: row + 1,
|
||||||
|
column: day,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final code = record[1]!,
|
final code = record[1]!,
|
||||||
name = record[2]!.trim(),
|
name = record[2]!.trim(),
|
||||||
teacher = record[5]!.trim(),
|
teacher = record[5]!.trim(),
|
||||||
room = record[9]!.trim();
|
rawRoom = record[9]!.trim(),
|
||||||
final weeks = _weeks(record[6]!);
|
room = rawRoom.isEmpty ? '教室待定' : rawRoom;
|
||||||
|
if (name.isEmpty || teacher.isEmpty) {
|
||||||
|
throw UnsupportedSchoolLayout(
|
||||||
|
code: 'course_record_invalid',
|
||||||
|
row: row + 1,
|
||||||
|
column: day,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final Set<int> weeks;
|
||||||
|
try {
|
||||||
|
weeks = _weeks(record[6]!);
|
||||||
|
} on UnsupportedSchoolLayout catch (error) {
|
||||||
|
throw UnsupportedSchoolLayout(
|
||||||
|
code: error.code,
|
||||||
|
row: row + 1,
|
||||||
|
column: day,
|
||||||
|
);
|
||||||
|
}
|
||||||
final identity = jsonEncode([
|
final identity = jsonEncode([
|
||||||
term,
|
term,
|
||||||
code,
|
code,
|
||||||
@@ -101,12 +190,15 @@ class SchoolPageAdapter {
|
|||||||
endPeriod: last,
|
endPeriod: last,
|
||||||
weeks: {...?previous?.weeks, ...weeks},
|
weeks: {...?previous?.weeks, ...weeks},
|
||||||
source: source,
|
source: source,
|
||||||
|
notes: rawRoom.isEmpty ? '学校未提供教室,请以学校最新安排为准并在公布后刷新课表。' : '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// An empty DOM can be an expired session or an incomplete response.
|
// An empty DOM can be an expired session or an incomplete response.
|
||||||
if (courses.isEmpty) throw UnsupportedSchoolLayout();
|
if (courses.isEmpty) {
|
||||||
|
throw const UnsupportedSchoolLayout(code: 'empty_timetable');
|
||||||
|
}
|
||||||
return ImportDraft(
|
return ImportDraft(
|
||||||
courses: List.unmodifiable(courses.values),
|
courses: List.unmodifiable(courses.values),
|
||||||
explicitlyEmpty: false,
|
explicitlyEmpty: false,
|
||||||
@@ -132,7 +224,7 @@ class SchoolPageAdapter {
|
|||||||
buffer.write('\n');
|
buffer.write('\n');
|
||||||
} else {
|
} else {
|
||||||
if (node.localName == 'script' || node.localName == 'style') {
|
if (node.localName == 'script' || node.localName == 'style') {
|
||||||
throw UnsupportedSchoolLayout();
|
throw const UnsupportedSchoolLayout(code: 'unsafe_markup');
|
||||||
}
|
}
|
||||||
for (final child in node.nodes) {
|
for (final child in node.nodes) {
|
||||||
visit(child);
|
visit(child);
|
||||||
@@ -157,11 +249,13 @@ class SchoolPageAdapter {
|
|||||||
for (final part in text.split(RegExp('[,,、]'))) {
|
for (final part in text.split(RegExp('[,,、]'))) {
|
||||||
final range = RegExp(r'^\s*(\d+)(?:\s*[-~~]\s*(\d+))?\s*$')
|
final range = RegExp(r'^\s*(\d+)(?:\s*[-~~]\s*(\d+))?\s*$')
|
||||||
.firstMatch(part);
|
.firstMatch(part);
|
||||||
if (range == null) throw UnsupportedSchoolLayout();
|
if (range == null) {
|
||||||
|
throw const UnsupportedSchoolLayout(code: 'weeks_invalid');
|
||||||
|
}
|
||||||
final first = int.parse(range[1]!),
|
final first = int.parse(range[1]!),
|
||||||
last = int.parse(range[2] ?? range[1]!);
|
last = int.parse(range[2] ?? range[1]!);
|
||||||
if (first < 1 || last > 30 || last < first) {
|
if (first < 1 || last > 30 || last < first) {
|
||||||
throw UnsupportedSchoolLayout();
|
throw const UnsupportedSchoolLayout(code: 'weeks_invalid');
|
||||||
}
|
}
|
||||||
result.addAll(List.generate(last - first + 1, (i) => first + i));
|
result.addAll(List.generate(last - first + 1, (i) => first + i));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
bool _routeClosed = false;
|
bool _routeClosed = false;
|
||||||
int _pollsLeft = 0;
|
int _pollsLeft = 0;
|
||||||
int? _probeGeneration;
|
int? _probeGeneration;
|
||||||
|
UnsupportedSchoolLayout? _lastLayoutFailure;
|
||||||
|
int? _layoutFailureGeneration;
|
||||||
SchoolConnectionState connectionState = SchoolConnectionState.signedOut;
|
SchoolConnectionState connectionState = SchoolConnectionState.signedOut;
|
||||||
bool _loading = false,
|
bool _loading = false,
|
||||||
hasOpened = false,
|
hasOpened = false,
|
||||||
@@ -334,9 +336,16 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
String get _findingMessage => requestingExams ? '正在查找考试安排,请稍候' : '正在查找课表,请稍候';
|
String get _findingMessage => requestingExams ? '正在查找考试安排,请稍候' : '正在查找课表,请稍候';
|
||||||
void _armTimeout() {
|
void _armTimeout() {
|
||||||
_timeout?.cancel();
|
_timeout?.cancel();
|
||||||
_timeout = Timer(
|
_timeout = Timer(const Duration(seconds: 45), _finishReadFailure);
|
||||||
const Duration(seconds: 45),
|
}
|
||||||
() => _failure('查找超时,请检查学校页面后重试。已有数据保持不变。', code: 'read_timeout'),
|
|
||||||
|
void _finishReadFailure() {
|
||||||
|
final error = _layoutFailureGeneration == _pageGeneration
|
||||||
|
? _lastLayoutFailure
|
||||||
|
: null;
|
||||||
|
_failure(
|
||||||
|
error == null ? '查找超时,请检查学校页面后重试。已有数据保持不变。' : '课表读取未完成:$error',
|
||||||
|
code: error?.code ?? 'read_timeout',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +405,7 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
_poll?.cancel();
|
_poll?.cancel();
|
||||||
if (_pollsLeft-- <= 0) {
|
if (_pollsLeft-- <= 0) {
|
||||||
if (finding) _failure('查找超时,请检查学校页面后重试。已有数据保持不变。', code: 'read_timeout');
|
if (finding) _finishReadFailure();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_poll = Timer(const Duration(milliseconds: 750), () {
|
_poll = Timer(const Duration(milliseconds: 750), () {
|
||||||
@@ -500,6 +509,8 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
// gets only its recognized fragment with the canonical table path.
|
// gets only its recognized fragment with the canonical table path.
|
||||||
final draft = await _reader.read(_target, () async => page.html);
|
final draft = await _reader.read(_target, () async => page.html);
|
||||||
if (!hasOpened || generation != _pageGeneration) return;
|
if (!hasOpened || generation != _pageGeneration) return;
|
||||||
|
_lastLayoutFailure = null;
|
||||||
|
_layoutFailureGeneration = null;
|
||||||
if (draft.schoolTerm != selectedTerm) {
|
if (draft.schoolTerm != selectedTerm) {
|
||||||
DiagnosticLog.instance.add(
|
DiagnosticLog.instance.add(
|
||||||
'term_mismatch',
|
'term_mismatch',
|
||||||
@@ -528,11 +539,24 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation != _pageGeneration) return;
|
||||||
|
if (error is UnsupportedSchoolLayout) {
|
||||||
|
_lastLayoutFailure = error;
|
||||||
|
_layoutFailureGeneration = generation;
|
||||||
|
message =
|
||||||
|
'课表暂未识别:${error.description}。正在等待页面加载完整;若仍失败,请在反馈中附带学校HTML和日志。';
|
||||||
|
}
|
||||||
DiagnosticLog.instance.add(
|
DiagnosticLog.instance.add(
|
||||||
'school_parse_rejected',
|
'school_parse_rejected',
|
||||||
fields: {
|
fields: {
|
||||||
'school': 'graduate',
|
'school': 'graduate',
|
||||||
'code': error.runtimeType.toString(),
|
'code': error is UnsupportedSchoolLayout
|
||||||
|
? error.code
|
||||||
|
: error.runtimeType.toString(),
|
||||||
|
if (error is UnsupportedSchoolLayout && error.row != null)
|
||||||
|
'row': error.row,
|
||||||
|
if (error is UnsupportedSchoolLayout && error.column != null)
|
||||||
|
'column': error.column,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (generation != _pageGeneration) return;
|
if (generation != _pageGeneration) return;
|
||||||
@@ -595,6 +619,7 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
liveUrl != uri.toString()) {
|
liveUrl != uri.toString()) {
|
||||||
throw UnsupportedSchoolLayout();
|
throw UnsupportedSchoolLayout();
|
||||||
}
|
}
|
||||||
|
UnsupportedSchoolLayout? rejected;
|
||||||
for (final page in evidence.pages) {
|
for (final page in evidence.pages) {
|
||||||
if (page.kind != 'timetable') continue;
|
if (page.kind != 'timetable') continue;
|
||||||
SchoolPageDiagnostic.instance.remember(page.html, page.url);
|
SchoolPageDiagnostic.instance.remember(page.html, page.url);
|
||||||
@@ -608,11 +633,12 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
if (draft.schoolTerm != selectedTerm) continue;
|
if (draft.schoolTerm != selectedTerm) continue;
|
||||||
return draft;
|
return draft;
|
||||||
} on UnsupportedSchoolLayout {
|
} on UnsupportedSchoolLayout catch (error) {
|
||||||
if (generation != _pageGeneration) rethrow;
|
if (generation != _pageGeneration) rethrow;
|
||||||
|
rejected = error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw UnsupportedSchoolLayout();
|
throw rejected ?? const UnsupportedSchoolLayout(code: 'table_missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> readCurrentPage() async {
|
Future<void> readCurrentPage() async {
|
||||||
|
|||||||
@@ -54,13 +54,6 @@ List<UndergraduateActivity> parseActivityData(
|
|||||||
i + 1,
|
i + 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (args[5].trim().isEmpty) {
|
|
||||||
throw UndergraduateActivityException(
|
|
||||||
'room_missing',
|
|
||||||
'课程活动未提供教室信息,请附带网页反馈',
|
|
||||||
i + 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final bitmap = args[6];
|
final bitmap = args[6];
|
||||||
if (!RegExp(r'^0[01]+$').hasMatch(bitmap) ||
|
if (!RegExp(r'^0[01]+$').hasMatch(bitmap) ||
|
||||||
bitmap.length > 64 ||
|
bitmap.length > 64 ||
|
||||||
@@ -109,7 +102,7 @@ List<UndergraduateActivity> parseActivityData(
|
|||||||
args[1].trim(),
|
args[1].trim(),
|
||||||
args[3].substring(0, suffix.start).trim(),
|
args[3].substring(0, suffix.start).trim(),
|
||||||
suffix[1]!,
|
suffix[1]!,
|
||||||
args[5].trim(),
|
args[5].trim().isEmpty ? '教室待定' : args[5].trim(),
|
||||||
indices,
|
indices,
|
||||||
weeks,
|
weeks,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -103,7 +103,13 @@ class _UndergraduateAuthPageState extends State<UndergraduateAuthPage> {
|
|||||||
if (route?.isCurrent == true) Navigator.pop(context);
|
if (route?.isCurrent == true) Navigator.pop(context);
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(exams == null ? '课表已导入,请核对学期开始日。' : '考试信息已更新'),
|
content: Text(
|
||||||
|
exams != null
|
||||||
|
? '考试信息已更新'
|
||||||
|
: draft!.courses.any((c) => c.room == '教室待定')
|
||||||
|
? '课表已导入,部分课程教室待定;请核对学期开始日。'
|
||||||
|
: '课表已导入,请核对学期开始日。',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (controller.lastError != null) {
|
} else if (controller.lastError != null) {
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ class UndergraduateTimetableAdapter {
|
|||||||
table = html
|
table = html
|
||||||
.parse(content)
|
.parse(content)
|
||||||
.querySelector('#manualArrangeCourseTable');
|
.querySelector('#manualArrangeCourseTable');
|
||||||
if (table == null || doc.querySelector('input[type="password"]') != null) {
|
if (table == null ||
|
||||||
|
doc.querySelector('input[type="password"]') != null) {
|
||||||
throw const FormatException('请先在学校网页打开课表');
|
throw const FormatException('请先在学校网页打开课表');
|
||||||
}
|
}
|
||||||
final termIds = doc
|
final termIds = doc
|
||||||
@@ -161,7 +162,10 @@ class UndergraduateTimetableAdapter {
|
|||||||
weeks: group.weeks,
|
weeks: group.weeks,
|
||||||
source: source,
|
source: source,
|
||||||
courseCode: info?.code ?? a.courseCode,
|
courseCode: info?.code ?? a.courseCode,
|
||||||
notes: info?.notes ?? '',
|
notes: [
|
||||||
|
if (info?.notes.isNotEmpty == true) info!.notes,
|
||||||
|
if (a.room == '教室待定') '学校未提供教室,请以学校最新安排为准并在公布后刷新课表。',
|
||||||
|
].join('\n'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ class WeekGrid extends StatelessWidget {
|
|||||||
final scale = scaler.scale(10) / 10;
|
final scale = scaler.scale(10) / 10;
|
||||||
const gutter = 34.0, gap = 16.0;
|
const gutter = 34.0, gap = 16.0;
|
||||||
final width = (constraints.maxWidth - gutter) / 7;
|
final width = (constraints.maxWidth - gutter) / 7;
|
||||||
|
final spacious = width >= 76;
|
||||||
|
final nameStyle = _nameStyle.copyWith(fontSize: spacious ? 13 : 10.5);
|
||||||
|
final metadataStyle = _metadataStyle.copyWith(
|
||||||
|
fontSize: spacious ? 11.5 : 9.5,
|
||||||
|
);
|
||||||
final header = 56.0 * scale.clamp(1, 2.5);
|
final header = 56.0 * scale.clamp(1, 2.5);
|
||||||
final groups = {for (final e in events) e: _group(e)};
|
final groups = {for (final e in events) e: _group(e)};
|
||||||
final defaultStyle = DefaultTextStyle.of(context).style;
|
final defaultStyle = DefaultTextStyle.of(context).style;
|
||||||
@@ -84,14 +89,14 @@ class WeekGrid extends StatelessWidget {
|
|||||||
var entryHeight = 0.0;
|
var entryHeight = 0.0;
|
||||||
for (final value in group) {
|
for (final value in group) {
|
||||||
final required =
|
final required =
|
||||||
measure(value.course.name, _nameStyle, available, lines: 2) +
|
measure(value.course.name, nameStyle, available, lines: 2) +
|
||||||
measure(value.course.teacher, _metadataStyle, available) +
|
measure(value.course.teacher, metadataStyle, available) +
|
||||||
measure(value.course.room, _metadataStyle, available) +
|
measure(value.course.room, metadataStyle, available) +
|
||||||
21;
|
21;
|
||||||
final periodHeight = group.length > 1
|
final periodHeight = group.length > 1
|
||||||
? measure(
|
? measure(
|
||||||
'${value.course.startPeriod}–${value.course.endPeriod}节',
|
'${value.course.startPeriod}–${value.course.endPeriod}节',
|
||||||
_metadataStyle,
|
metadataStyle,
|
||||||
available,
|
available,
|
||||||
) +
|
) +
|
||||||
3
|
3
|
||||||
@@ -101,7 +106,7 @@ class WeekGrid extends StatelessWidget {
|
|||||||
final first = group.map((v) => v.course.startPeriod).reduce(math.min);
|
final first = group.map((v) => v.course.startPeriod).reduce(math.min);
|
||||||
final last = group.map((v) => v.course.endPeriod).reduce(math.max);
|
final last = group.map((v) => v.course.endPeriod).reduce(math.max);
|
||||||
final warning = group.length > 1
|
final warning = group.length > 1
|
||||||
? measure('冲突列表', _metadataStyle, width - 12) + 8
|
? measure('冲突列表', metadataStyle, width - 12) + 8
|
||||||
: 0.0;
|
: 0.0;
|
||||||
rowHeight = math.max(
|
rowHeight = math.max(
|
||||||
rowHeight,
|
rowHeight,
|
||||||
@@ -133,8 +138,8 @@ class WeekGrid extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${slot.period}',
|
'${slot.period}',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: spacious ? 13 : 11,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -143,7 +148,7 @@ class WeekGrid extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
minuteLabel(slot.startMinute),
|
minuteLabel(slot.startMinute),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 8,
|
fontSize: spacious ? 10 : 8,
|
||||||
color: ink.withValues(alpha: 0.55),
|
color: ink.withValues(alpha: 0.55),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -152,7 +157,7 @@ class WeekGrid extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
minuteLabel(slot.endMinute),
|
minuteLabel(slot.endMinute),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 8,
|
fontSize: spacious ? 10 : 8,
|
||||||
color: ink.withValues(alpha: 0.4),
|
color: ink.withValues(alpha: 0.4),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -192,7 +197,7 @@ class WeekGrid extends StatelessWidget {
|
|||||||
][i],
|
][i],
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: spacious ? 12 : 10,
|
||||||
color: active
|
color: active
|
||||||
? river
|
? river
|
||||||
: ink.withValues(alpha: 0.6),
|
: ink.withValues(alpha: 0.6),
|
||||||
@@ -211,7 +216,7 @@ class WeekGrid extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
'${date.day}',
|
'${date.day}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: spacious ? 13 : 11,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: active ? Colors.white : ink,
|
color: active ? Colors.white : ink,
|
||||||
),
|
),
|
||||||
@@ -258,7 +263,15 @@ class WeekGrid extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
for (final e in events)
|
for (final e in events)
|
||||||
if (groups[e]!.first == e)
|
if (groups[e]!.first == e)
|
||||||
_block(context, groups[e]!, width, rowHeight, top),
|
_block(
|
||||||
|
context,
|
||||||
|
groups[e]!,
|
||||||
|
width,
|
||||||
|
rowHeight,
|
||||||
|
top,
|
||||||
|
nameStyle,
|
||||||
|
metadataStyle,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -276,6 +289,8 @@ class WeekGrid extends StatelessWidget {
|
|||||||
double width,
|
double width,
|
||||||
double rowHeight,
|
double rowHeight,
|
||||||
double Function(int) top,
|
double Function(int) top,
|
||||||
|
TextStyle nameStyle,
|
||||||
|
TextStyle metadataStyle,
|
||||||
) {
|
) {
|
||||||
final first = group.map((e) => e.course.startPeriod).reduce(math.min);
|
final first = group.map((e) => e.course.startPeriod).reduce(math.min);
|
||||||
final last = group.map((e) => e.course.endPeriod).reduce(math.max);
|
final last = group.map((e) => e.course.endPeriod).reduce(math.max);
|
||||||
@@ -285,7 +300,12 @@ class WeekGrid extends StatelessWidget {
|
|||||||
width: width - 4,
|
width: width - 4,
|
||||||
height: (last - first + 1) * rowHeight - 8,
|
height: (last - first + 1) * rowHeight - 8,
|
||||||
child: group.length == 1
|
child: group.length == 1
|
||||||
? _entry(context, group.first)
|
? _entry(
|
||||||
|
context,
|
||||||
|
group.first,
|
||||||
|
nameStyle: nameStyle,
|
||||||
|
metadataStyle: metadataStyle,
|
||||||
|
)
|
||||||
: Container(
|
: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: river.withValues(alpha: 0.06),
|
color: river.withValues(alpha: 0.06),
|
||||||
@@ -308,7 +328,15 @@ class WeekGrid extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
for (final e in group)
|
for (final e in group)
|
||||||
Expanded(child: _entry(context, e, inConflict: true)),
|
Expanded(
|
||||||
|
child: _entry(
|
||||||
|
context,
|
||||||
|
e,
|
||||||
|
inConflict: true,
|
||||||
|
nameStyle: nameStyle,
|
||||||
|
metadataStyle: metadataStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -319,6 +347,8 @@ class WeekGrid extends StatelessWidget {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
CourseOccurrence e, {
|
CourseOccurrence e, {
|
||||||
bool inConflict = false,
|
bool inConflict = false,
|
||||||
|
required TextStyle nameStyle,
|
||||||
|
required TextStyle metadataStyle,
|
||||||
}) => Semantics(
|
}) => Semantics(
|
||||||
label: '${e.course.name},${e.course.teacher},${e.course.room}',
|
label: '${e.course.name},${e.course.teacher},${e.course.room}',
|
||||||
button: true,
|
button: true,
|
||||||
@@ -343,26 +373,24 @@ class WeekGrid extends StatelessWidget {
|
|||||||
e.course.name,
|
e.course.name,
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: _nameStyle,
|
style: nameStyle,
|
||||||
),
|
),
|
||||||
if (inConflict) ...[
|
if (inConflict) ...[
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
Text(
|
Text(
|
||||||
'${e.course.startPeriod}–${e.course.endPeriod}节',
|
'${e.course.startPeriod}–${e.course.endPeriod}节',
|
||||||
style: _metadataStyle,
|
style: metadataStyle,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
e.course.teacher,
|
e.course.teacher,
|
||||||
style: _metadataStyle.copyWith(
|
style: metadataStyle.copyWith(color: ink.withValues(alpha: 0.85)),
|
||||||
color: ink.withValues(alpha: 0.85),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
Text(
|
Text(
|
||||||
e.course.room,
|
e.course.room,
|
||||||
style: _metadataStyle.copyWith(color: ink.withValues(alpha: 0.7)),
|
style: metadataStyle.copyWith(color: ink.withValues(alpha: 0.7)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class DiagnosticLog {
|
|||||||
'code',
|
'code',
|
||||||
'version',
|
'version',
|
||||||
'result',
|
'result',
|
||||||
|
'row',
|
||||||
|
'column',
|
||||||
};
|
};
|
||||||
final DateTime Function() clock;
|
final DateTime Function() clock;
|
||||||
final _entries = <Map<String, dynamic>>[];
|
final _entries = <Map<String, dynamic>>[];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:flutter/services.dart';
|
|||||||
import '../domain/course.dart';
|
import '../domain/course.dart';
|
||||||
import '../domain/app_settings.dart';
|
import '../domain/app_settings.dart';
|
||||||
import '../domain/course_occurrence.dart';
|
import '../domain/course_occurrence.dart';
|
||||||
|
import 'diagnostic_log.dart';
|
||||||
import 'notification_service.dart';
|
import 'notification_service.dart';
|
||||||
|
|
||||||
class ReminderPermissions {
|
class ReminderPermissions {
|
||||||
@@ -33,6 +34,7 @@ class ReminderRequest {
|
|||||||
abstract class ReminderGateway {
|
abstract class ReminderGateway {
|
||||||
Future<ReminderPermissions> permissions({required bool request});
|
Future<ReminderPermissions> permissions({required bool request});
|
||||||
Future<Set<int>> pendingIds();
|
Future<Set<int>> pendingIds();
|
||||||
|
Future<Set<int>> missingSystemIds(Set<int> ids) async => {};
|
||||||
Future<Set<int>> ownedIds() => pendingIds();
|
Future<Set<int>> ownedIds() => pendingIds();
|
||||||
Future<String> sendTestReminder({required bool scheduled}) async =>
|
Future<String> sendTestReminder({required bool scheduled}) async =>
|
||||||
'当前环境不支持通知测试';
|
'当前环境不支持通知测试';
|
||||||
@@ -72,7 +74,8 @@ class LocalNotificationService extends NotificationService {
|
|||||||
await cancelOwned();
|
await cancelOwned();
|
||||||
return const ReminderResult(
|
return const ReminderResult(
|
||||||
ReminderStatus.permissionDenied,
|
ReminderStatus.permissionDenied,
|
||||||
message: '请允许通知,并检查系统中的“课前提醒”通知类别。',
|
message:
|
||||||
|
'通知未获允许,当前没有安排课前提醒。请在“通知设置”开启TPSchedule的通知总开关和“课前提醒”类别,再点“检查提醒”。',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final upcoming =
|
final upcoming =
|
||||||
@@ -122,6 +125,8 @@ class LocalNotificationService extends NotificationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final pending = await gateway.pendingIds();
|
final pending = await gateway.pendingIds();
|
||||||
|
// Include overdue entries so warm-resume diagnostics survive their removal.
|
||||||
|
final missing = await gateway.missingSystemIds(pending);
|
||||||
for (final old in (await gateway.ownedIds()).difference(
|
for (final old in (await gateway.ownedIds()).difference(
|
||||||
desired.keys.toSet(),
|
desired.keys.toSet(),
|
||||||
)) {
|
)) {
|
||||||
@@ -133,6 +138,7 @@ class LocalNotificationService extends NotificationService {
|
|||||||
final fingerprint =
|
final fingerprint =
|
||||||
'${request.fireAt.millisecondsSinceEpoch}/${request.title}/${request.body}/${request.exact}';
|
'${request.fireAt.millisecondsSinceEpoch}/${request.title}/${request.body}/${request.exact}';
|
||||||
if (pending.contains(entry.key) &&
|
if (pending.contains(entry.key) &&
|
||||||
|
!missing.contains(entry.key) &&
|
||||||
_fingerprints[entry.key] == fingerprint) {
|
_fingerprints[entry.key] == fingerprint) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -144,7 +150,7 @@ class LocalNotificationService extends NotificationService {
|
|||||||
if (!saved.containsAll(desired.keys)) {
|
if (!saved.containsAll(desired.keys)) {
|
||||||
throw const ReminderSetupException(
|
throw const ReminderSetupException(
|
||||||
'schedule_not_saved',
|
'schedule_not_saved',
|
||||||
'系统未保存全部提醒,请点击“检查提醒”后重试。',
|
'部分提醒尚未完成登记,请点击“检查提醒”后重试。',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final message = [
|
final message = [
|
||||||
@@ -191,6 +197,12 @@ class PluginReminderGateway extends ReminderGateway {
|
|||||||
final void Function(String key)? onTap;
|
final void Function(String key)? onTap;
|
||||||
final _plugin = FlutterLocalNotificationsPlugin();
|
final _plugin = FlutterLocalNotificationsPlugin();
|
||||||
static const _system = MethodChannel('com.tpschedule.app/reminders');
|
static const _system = MethodChannel('com.tpschedule.app/reminders');
|
||||||
|
static const _courseChannel = AndroidNotificationChannel(
|
||||||
|
'hepan_lessons',
|
||||||
|
'课前提醒',
|
||||||
|
description: '课程开始前的提醒',
|
||||||
|
importance: Importance.high,
|
||||||
|
);
|
||||||
String? _activeQueryWarning;
|
String? _activeQueryWarning;
|
||||||
Future<void>? _initializing;
|
Future<void>? _initializing;
|
||||||
Future<void> initialize() => _initializing ??= Future<void>(() async {
|
Future<void> initialize() => _initializing ??= Future<void>(() async {
|
||||||
@@ -205,6 +217,9 @@ class PluginReminderGateway extends ReminderGateway {
|
|||||||
if (ready != true) {
|
if (ready != true) {
|
||||||
throw StateError('通知服务初始化失败');
|
throw StateError('通知服务初始化失败');
|
||||||
}
|
}
|
||||||
|
// Make the category visible before checking access or scheduling.
|
||||||
|
// Reusing its ID preserves the user's existing Android channel choices.
|
||||||
|
await _android?.createNotificationChannel(_courseChannel);
|
||||||
final launch = await _plugin.getNotificationAppLaunchDetails();
|
final launch = await _plugin.getNotificationAppLaunchDetails();
|
||||||
if (launch?.didNotificationLaunchApp == true) {
|
if (launch?.didNotificationLaunchApp == true) {
|
||||||
_select(launch?.notificationResponse?.payload);
|
_select(launch?.notificationResponse?.payload);
|
||||||
@@ -239,9 +254,20 @@ class PluginReminderGateway extends ReminderGateway {
|
|||||||
channel.id == 'hepan_lessons' &&
|
channel.id == 'hepan_lessons' &&
|
||||||
channel.importance == Importance.none,
|
channel.importance == Importance.none,
|
||||||
);
|
);
|
||||||
|
final appEnabled = await _android?.areNotificationsEnabled() ?? false;
|
||||||
|
DiagnosticLog.instance.add(
|
||||||
|
'reminder_access_checked',
|
||||||
|
fields: {
|
||||||
|
'result': appEnabled && courseChannelEnabled,
|
||||||
|
'code': !appEnabled
|
||||||
|
? 'app_notifications_disabled'
|
||||||
|
: !courseChannelEnabled
|
||||||
|
? 'course_channel_disabled'
|
||||||
|
: 'allowed',
|
||||||
|
},
|
||||||
|
);
|
||||||
return ReminderPermissions(
|
return ReminderPermissions(
|
||||||
(await _android?.areNotificationsEnabled() ?? false) &&
|
appEnabled && courseChannelEnabled,
|
||||||
courseChannelEnabled,
|
|
||||||
await _android?.canScheduleExactNotifications() ?? false,
|
await _android?.canScheduleExactNotifications() ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -275,6 +301,20 @@ class PluginReminderGateway extends ReminderGateway {
|
|||||||
return owned;
|
return owned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Set<int>> missingSystemIds(Set<int> ids) async {
|
||||||
|
if (ids.isEmpty) return {};
|
||||||
|
try {
|
||||||
|
final missing = await _system
|
||||||
|
.invokeListMethod<int>('findMissingAlarmHandles', ids.toList())
|
||||||
|
.timeout(const Duration(seconds: 2));
|
||||||
|
return missing?.where(ids.contains).toSet() ?? {};
|
||||||
|
} catch (_) {
|
||||||
|
// Availability of this extra check must not block normal scheduling.
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String> sendTestReminder({required bool scheduled}) async {
|
Future<String> sendTestReminder({required bool scheduled}) async {
|
||||||
final access = await permissions(request: false);
|
final access = await permissions(request: false);
|
||||||
@@ -305,7 +345,7 @@ class PluginReminderGateway extends ReminderGateway {
|
|||||||
);
|
);
|
||||||
final saved = await _plugin.pendingNotificationRequests();
|
final saved = await _plugin.pendingNotificationRequests();
|
||||||
if (!saved.any((item) => item.id == -1002)) {
|
if (!saved.any((item) => item.id == -1002)) {
|
||||||
throw const ReminderSetupException('test_not_saved', '系统未保存测试提醒。');
|
throw const ReminderSetupException('test_not_saved', '测试提醒登记未完成。');
|
||||||
}
|
}
|
||||||
return '已安排测试,请按手机 Home 键回到桌面,10 秒后查看通知栏。';
|
return '已安排测试,请按手机 Home 键回到桌面,10 秒后查看通知栏。';
|
||||||
}
|
}
|
||||||
@@ -327,6 +367,7 @@ class PluginReminderGateway extends ReminderGateway {
|
|||||||
final channel = channels
|
final channel = channels
|
||||||
?.where((item) => item.id == 'hepan_lessons')
|
?.where((item) => item.id == 'hepan_lessons')
|
||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
|
final appEnabled = await _android?.areNotificationsEnabled();
|
||||||
Map<Object?, Object?>? system;
|
Map<Object?, Object?>? system;
|
||||||
String? systemError;
|
String? systemError;
|
||||||
try {
|
try {
|
||||||
@@ -338,6 +379,11 @@ class PluginReminderGateway extends ReminderGateway {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
'notifications': access.notifications,
|
'notifications': access.notifications,
|
||||||
|
'appNotificationsEnabled': appEnabled,
|
||||||
|
'channelExists': channels == null ? null : channel != null,
|
||||||
|
'courseChannelEnabled': channel == null
|
||||||
|
? null
|
||||||
|
: channel.importance != Importance.none,
|
||||||
'exactAlarm': access.exactAlarm,
|
'exactAlarm': access.exactAlarm,
|
||||||
'pendingCount': pending.length,
|
'pendingCount': pending.length,
|
||||||
'channelImportance': channel?.importance.value,
|
'channelImportance': channel?.importance.value,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// Fits the current window, retaining child state during split-screen resizing.
|
||||||
|
class AdaptivePage extends StatelessWidget {
|
||||||
|
const AdaptivePage({super.key, required this.child, this.maxWidth = 840});
|
||||||
|
final Widget child;
|
||||||
|
final double maxWidth;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final width = constraints.maxWidth;
|
||||||
|
final contentWidth = width >= 600
|
||||||
|
? math.min(maxWidth, width - 32)
|
||||||
|
: width;
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: SizedBox(
|
||||||
|
width: contentWidth,
|
||||||
|
height: constraints.maxHeight,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
+456
-147
@@ -3,6 +3,7 @@ import 'dart:ui' as ui;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/physics.dart';
|
import 'package:flutter/physics.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
|
||||||
import 'glass_refraction.dart';
|
import 'glass_refraction.dart';
|
||||||
|
|
||||||
@@ -19,8 +20,11 @@ class GlassDock extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
|
class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
|
||||||
static const _width = 264.0, _height = 58.0, _inset = 3.0, _travel = 86.0;
|
static const _height = 58.0, _inset = 3.0;
|
||||||
|
double _width = 264;
|
||||||
|
double get _travel => (_width - _inset * 2) / 3;
|
||||||
static const _blue = Color(0xFF007AFF);
|
static const _blue = Color(0xFF007AFF);
|
||||||
|
static const _inactive = Color(0xFF7C8593);
|
||||||
late final AnimationController _motion = AnimationController.unbounded(
|
late final AnimationController _motion = AnimationController.unbounded(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
value: widget.selectedIndex.toDouble(),
|
value: widget.selectedIndex.toDouble(),
|
||||||
@@ -29,16 +33,125 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
|
|||||||
vsync: this,
|
vsync: this,
|
||||||
value: 0,
|
value: 0,
|
||||||
);
|
);
|
||||||
|
late final AnimationController _tap = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 420),
|
||||||
|
);
|
||||||
|
late final AnimationController _sway = AnimationController.unbounded(
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
late final AnimationController _ripple = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 620),
|
||||||
|
);
|
||||||
|
void _wakeRipple() {
|
||||||
|
if (!_reduceMotion && !_ripple.isAnimating) _ripple.forward(from: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _swayTarget = 0, _swayVelocity = 0, _dragTarget = 0, _dragVelocity = 0;
|
||||||
|
Duration? _elasticLastTick;
|
||||||
|
late final Ticker _elasticTicker = createTicker(_tickElastic);
|
||||||
|
void _startElastic() {
|
||||||
|
if (!_elasticTicker.isActive) {
|
||||||
|
_elasticLastTick = null;
|
||||||
|
_elasticTicker.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep one clock alive while input changes its targets. Restarting a spring
|
||||||
|
// on every pointer event repeatedly paints t=0 under continuous dragging.
|
||||||
|
void _tickElastic(Duration elapsed) {
|
||||||
|
final seconds = _elasticLastTick == null
|
||||||
|
? 1 / 60
|
||||||
|
: ((elapsed - _elasticLastTick!).inMicroseconds / 1000000).clamp(
|
||||||
|
0.0,
|
||||||
|
0.25,
|
||||||
|
);
|
||||||
|
_elasticLastTick = elapsed;
|
||||||
|
if (_drag == null) {
|
||||||
|
_swayTarget = _tap.isAnimating
|
||||||
|
? (_motion.velocity / 14).clamp(-0.85, 0.85)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
final sway = _advanceSpring(
|
||||||
|
_sway.value,
|
||||||
|
_swayVelocity,
|
||||||
|
_swayTarget,
|
||||||
|
220,
|
||||||
|
18,
|
||||||
|
seconds,
|
||||||
|
);
|
||||||
|
final drag = _advanceSpring(
|
||||||
|
_dragFollow.value,
|
||||||
|
_dragVelocity,
|
||||||
|
_dragTarget,
|
||||||
|
680,
|
||||||
|
28,
|
||||||
|
seconds,
|
||||||
|
);
|
||||||
|
_swayVelocity = sway.$2;
|
||||||
|
_dragVelocity = drag.$2;
|
||||||
|
_sway.value = sway.$1;
|
||||||
|
_dragFollow.value = drag.$1;
|
||||||
|
if (!_tap.isAnimating &&
|
||||||
|
(_sway.value - _swayTarget).abs() < 0.0005 &&
|
||||||
|
_swayVelocity.abs() < 0.005 &&
|
||||||
|
(_dragFollow.value - _dragTarget).abs() < 0.0005 &&
|
||||||
|
_dragVelocity.abs() < 0.005) {
|
||||||
|
_sway.value = _swayTarget;
|
||||||
|
_dragFollow.value = _dragTarget;
|
||||||
|
_swayVelocity = _dragVelocity = 0;
|
||||||
|
_elasticTicker.stop();
|
||||||
|
_elasticLastTick = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(double, double) _advanceSpring(
|
||||||
|
double value,
|
||||||
|
double velocity,
|
||||||
|
double target,
|
||||||
|
double stiffness,
|
||||||
|
double damping,
|
||||||
|
double seconds,
|
||||||
|
) {
|
||||||
|
final steps = math.max(1, (seconds * 120).ceil());
|
||||||
|
final step = seconds / steps;
|
||||||
|
for (var i = 0; i < steps; i++) {
|
||||||
|
velocity += (stiffness * (target - value) - damping * velocity) * step;
|
||||||
|
value += velocity * step;
|
||||||
|
}
|
||||||
|
return (value, velocity);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _swayTo(double target) {
|
||||||
|
_swayTarget = _reduceMotion ? 0 : target;
|
||||||
|
if (_reduceMotion) {
|
||||||
|
_sway.value = 0;
|
||||||
|
_swayVelocity = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_startElastic();
|
||||||
|
}
|
||||||
|
|
||||||
bool _reduceMotion = false;
|
bool _reduceMotion = false;
|
||||||
late int _targetIndex;
|
late int _targetIndex;
|
||||||
bool _elasticMotion = false;
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_targetIndex = widget.selectedIndex;
|
_targetIndex = widget.selectedIndex;
|
||||||
|
_dragTarget = widget.selectedIndex.toDouble();
|
||||||
}
|
}
|
||||||
|
|
||||||
double get _pressure => _press.value.clamp(0.0, 1.18);
|
double get _pressure => _press.value.clamp(0.0, 1.18);
|
||||||
|
double get _tapBloom {
|
||||||
|
if (_reduceMotion) return 0;
|
||||||
|
final t = _tap.value;
|
||||||
|
final tail = t <= 0.5
|
||||||
|
? 1.0
|
||||||
|
: Curves.easeInOut.transform((2 * (1 - t)).clamp(0.0, 1.0));
|
||||||
|
return math.sin(math.pi * t) * tail;
|
||||||
|
}
|
||||||
|
|
||||||
void _pressTo(double target) {
|
void _pressTo(double target) {
|
||||||
if (_reduceMotion) {
|
if (_reduceMotion) {
|
||||||
_press.value = target;
|
_press.value = target;
|
||||||
@@ -58,42 +171,71 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
|
|||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
_reduceMotion = MediaQuery.disableAnimationsOf(context);
|
_reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||||
|
if (_reduceMotion) {
|
||||||
|
_motion.value = widget.selectedIndex.toDouble();
|
||||||
|
_press.value = 0;
|
||||||
|
_tap.value = 0;
|
||||||
|
_ripple.value = 0;
|
||||||
|
_elasticTicker.stop();
|
||||||
|
_elasticLastTick = null;
|
||||||
|
_swayTarget = _swayVelocity = _dragVelocity = 0;
|
||||||
|
_dragTarget = _drag ?? widget.selectedIndex.toDouble();
|
||||||
|
_dragFollow.value = _drag ?? widget.selectedIndex.toDouble();
|
||||||
|
_sway.value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
late final AnimationController _dragFollow = AnimationController.unbounded(
|
||||||
|
vsync: this,
|
||||||
|
value: widget.selectedIndex.toDouble(),
|
||||||
|
);
|
||||||
|
void _followDragTo(double target) {
|
||||||
|
_dragTarget = target;
|
||||||
|
if (_reduceMotion) {
|
||||||
|
_dragFollow.value = target;
|
||||||
|
_dragVelocity = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_startElastic();
|
||||||
}
|
}
|
||||||
|
|
||||||
double? _drag;
|
double? _drag;
|
||||||
double _dragSpeed = 0;
|
double _dragSpeed = 0;
|
||||||
double get _position => _drag ?? _motion.value.clamp(-0.12, 2.12);
|
double get _position =>
|
||||||
void _moveTo(int index, {bool elastic = false, double velocity = 0}) {
|
(_drag != null ? _dragFollow.value : _motion.value).clamp(-0.12, 2.12);
|
||||||
|
void _moveTo(int index, {double velocity = 0, bool springyTap = false}) {
|
||||||
_targetIndex = index;
|
_targetIndex = index;
|
||||||
_elasticMotion = elastic && !_reduceMotion;
|
|
||||||
if (_reduceMotion) {
|
if (_reduceMotion) {
|
||||||
_motion.value = index.toDouble();
|
_motion.value = index.toDouble();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (elastic) {
|
_motion.animateWith(
|
||||||
_motion.animateWith(
|
SpringSimulation(
|
||||||
SpringSimulation(
|
springyTap
|
||||||
const SpringDescription(mass: 1, stiffness: 380, damping: 19),
|
? const SpringDescription(mass: 1, stiffness: 620, damping: 36)
|
||||||
_position,
|
: const SpringDescription(mass: 1, stiffness: 360, damping: 26),
|
||||||
index.toDouble(),
|
_position,
|
||||||
velocity.clamp(-8, 8),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
_motion.animateTo(
|
|
||||||
index.toDouble(),
|
index.toDouble(),
|
||||||
duration: const Duration(milliseconds: 320),
|
velocity,
|
||||||
curve: Curves.easeOutCubic,
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _select(int index, {bool elastic = false, double velocity = 0}) {
|
void _select(int index, {bool elastic = false, double velocity = 0}) {
|
||||||
final position = _position;
|
final position = _position;
|
||||||
|
final releaseVelocity = elastic
|
||||||
|
? velocity.clamp(-8.0, 8.0)
|
||||||
|
: _motion.velocity;
|
||||||
setState(() => _drag = null);
|
setState(() => _drag = null);
|
||||||
_motion.value = position;
|
_motion.value = position;
|
||||||
_moveTo(index, elastic: elastic, velocity: velocity);
|
_moveTo(index, velocity: releaseVelocity, springyTap: !elastic);
|
||||||
|
// Retarget the position spring without resetting an in-flight size pulse.
|
||||||
|
// Restarting that pulse at its peak made the glass edges jump on quick taps.
|
||||||
|
if (!elastic && !_reduceMotion && !_tap.isAnimating) {
|
||||||
|
_tap.forward(from: 0);
|
||||||
|
}
|
||||||
_pressTo(0);
|
_pressTo(0);
|
||||||
|
_swayTo(0);
|
||||||
widget.onSelected(index);
|
widget.onSelected(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,19 +245,24 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
|
|||||||
// The parent acknowledges onSelected with a rebuild. Restarting here used
|
// The parent acknowledges onSelected with a rebuild. Restarting here used
|
||||||
// to erase the release velocity and start a second spring for the same tab.
|
// to erase the release velocity and start a second spring for the same tab.
|
||||||
if (widget.selectedIndex != _targetIndex && _drag == null) {
|
if (widget.selectedIndex != _targetIndex && _drag == null) {
|
||||||
_moveTo(widget.selectedIndex);
|
_moveTo(widget.selectedIndex, velocity: _motion.velocity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_elasticTicker.dispose();
|
||||||
_motion.dispose();
|
_motion.dispose();
|
||||||
_press.dispose();
|
_press.dispose();
|
||||||
|
_tap.dispose();
|
||||||
|
_sway.dispose();
|
||||||
|
_ripple.dispose();
|
||||||
|
_dragFollow.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _glass({required Widget child, bool lens = false}) {
|
Widget _glass({required Widget child, bool lens = false}) {
|
||||||
final pressure = _pressure.clamp(0.0, 1.0);
|
final pressure = math.max(_pressure.clamp(0.0, 1.0), _tapBloom * 0.85);
|
||||||
final content = CustomPaint(
|
final content = CustomPaint(
|
||||||
foregroundPainter: _GlassRimPainter(lens: lens, pressure: pressure),
|
foregroundPainter: _GlassRimPainter(lens: lens, pressure: pressure),
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
@@ -126,25 +273,25 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
|
|||||||
colors: lens
|
colors: lens
|
||||||
? [
|
? [
|
||||||
Color.lerp(
|
Color.lerp(
|
||||||
const Color(0xFFA1A4AA).withValues(alpha: 0.34),
|
const Color(0xFFA1A4AA).withValues(alpha: 0.28),
|
||||||
const Color(0xFFD8DBE0).withValues(alpha: 0.0252),
|
const Color(0xFFD8DBE0).withValues(alpha: 0.0252),
|
||||||
pressure,
|
pressure,
|
||||||
)!,
|
)!,
|
||||||
Color.lerp(
|
Color.lerp(
|
||||||
const Color(0xFF8E939B).withValues(alpha: 0.26),
|
const Color(0xFF8E939B).withValues(alpha: 0.21),
|
||||||
const Color(0xFFC3C7CE).withValues(alpha: 0.0144),
|
const Color(0xFFC3C7CE).withValues(alpha: 0.0144),
|
||||||
pressure,
|
pressure,
|
||||||
)!,
|
)!,
|
||||||
Color.lerp(
|
Color.lerp(
|
||||||
const Color(0xFFB4B7BC).withValues(alpha: 0.32),
|
const Color(0xFFB4B7BC).withValues(alpha: 0.26),
|
||||||
const Color(0xFFE5E7EB).withValues(alpha: 0.0216),
|
const Color(0xFFE5E7EB).withValues(alpha: 0.0216),
|
||||||
pressure,
|
pressure,
|
||||||
)!,
|
)!,
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
Colors.white.withValues(alpha: 0.05),
|
Colors.white.withValues(alpha: 0.41),
|
||||||
_blue.withValues(alpha: 0.008),
|
const Color(0xFFFDFEFF).withValues(alpha: 0.25),
|
||||||
Colors.white.withValues(alpha: 0.018),
|
Colors.white.withValues(alpha: 0.34),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -154,75 +301,175 @@ 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.70 + pressure * 0.70 : 0.95,
|
strength: lens ? 1.0 + pressure * 0.78 : 1.18,
|
||||||
blurSigma: lens ? 1.6 - pressure * 0.6 : 1.4,
|
blurSigma: lens ? 1.6 - pressure * 0.6 : 1.7,
|
||||||
child: content,
|
child: content,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => AnimatedBuilder(
|
Widget build(BuildContext context) => LayoutBuilder(
|
||||||
animation: Listenable.merge([_motion, _press]),
|
builder: (context, constraints) {
|
||||||
builder: (context, _) {
|
_width = math.min(
|
||||||
final press = _pressure;
|
constraints.maxWidth,
|
||||||
final speed = _reduceMotion
|
MediaQuery.sizeOf(context).width >= 600 ? 300.0 : 264.0,
|
||||||
? 0.0
|
);
|
||||||
: (_drag != null
|
final viewport = MediaQuery.sizeOf(context).width;
|
||||||
? _dragSpeed
|
final availableWidth = math.min(
|
||||||
: _elasticMotion
|
viewport > 0 ? viewport : constraints.maxWidth,
|
||||||
? _motion.velocity
|
constraints.maxWidth,
|
||||||
: 0.0)
|
);
|
||||||
.abs();
|
final edgeAllowance = math.max(0.0, (availableWidth - _width) / 2 - 8);
|
||||||
final stretch = (speed / 16).clamp(0.0, 0.14) + press * 0.30;
|
return AnimatedBuilder(
|
||||||
final lensWidth = _travel * (1 + stretch);
|
animation: Listenable.merge([
|
||||||
final lensHeight =
|
_motion,
|
||||||
(_height - _inset * 2) *
|
_press,
|
||||||
(1 + press * 0.31 - (speed / 16).clamp(0.0, 0.07));
|
_tap,
|
||||||
return SizedBox(
|
_sway,
|
||||||
width: _width,
|
_ripple,
|
||||||
height: _height,
|
_dragFollow,
|
||||||
child: DecoratedBox(
|
]),
|
||||||
decoration: BoxDecoration(
|
builder: (context, _) {
|
||||||
borderRadius: BorderRadius.circular(40),
|
final press = _pressure;
|
||||||
boxShadow: [
|
final pulse = _reduceMotion ? 0.0 : math.sin(math.pi * _tap.value);
|
||||||
BoxShadow(
|
// Travel stretches horizontally; landing squeezes then rebounds
|
||||||
color: const Color(0xFF4C6078).withValues(alpha: 0.075),
|
// vertically. Endpoints are zero so quick retargets remain continuous.
|
||||||
blurRadius: 16,
|
final tapWave = _reduceMotion
|
||||||
offset: const Offset(0, 5),
|
? 0.0
|
||||||
),
|
: math.sin(2 * math.pi * _tap.value);
|
||||||
],
|
final tapStretch = tapWave >= 0 ? tapWave * 0.24 : tapWave * 0.08;
|
||||||
),
|
final tapRise = tapWave >= 0 ? -tapWave * 0.045 : -tapWave * 0.15;
|
||||||
child: Stack(
|
// Pointer movement wakes a finite ripple; pauses do not start idle loops.
|
||||||
clipBehavior: Clip.none,
|
final dragWave = _reduceMotion
|
||||||
children: [
|
? 0.0
|
||||||
Positioned.fill(child: _glass(child: const SizedBox.expand())),
|
: math.sin(2 * math.pi * _ripple.value) * press.clamp(0.0, 1.0);
|
||||||
Positioned(
|
final dragStretch = dragWave * (dragWave >= 0 ? 0.10 : 0.06);
|
||||||
left: (_inset + _position * _travel - (lensWidth - _travel) / 2)
|
final tapInflate = _tapBloom * 0.30;
|
||||||
.clamp(1.0, _width - lensWidth - 1),
|
final speed = _reduceMotion
|
||||||
top: (_height - lensHeight) / 2,
|
? 0.0
|
||||||
width: lensWidth,
|
: (_drag != null ? _dragSpeed : _motion.velocity).abs();
|
||||||
height: lensHeight,
|
final stretch =
|
||||||
child: IgnorePointer(
|
(speed / 80).clamp(0.0, 0.08) +
|
||||||
child: _glass(lens: true, child: const SizedBox.expand()),
|
tapStretch +
|
||||||
|
tapInflate +
|
||||||
|
press * 0.30 +
|
||||||
|
dragStretch;
|
||||||
|
final lensWidth = _travel * (1 + stretch);
|
||||||
|
final lensHeight =
|
||||||
|
(_height - _inset * 2) *
|
||||||
|
(1 +
|
||||||
|
press * 0.31 +
|
||||||
|
tapRise +
|
||||||
|
tapInflate -
|
||||||
|
dragWave * 0.08 -
|
||||||
|
(speed / 180).clamp(0.0, 0.035));
|
||||||
|
// Resize real glass bounds: sampling stays aligned without scaling text.
|
||||||
|
final sway = _reduceMotion ? 0.0 : _sway.value.clamp(-1.2, 1.2);
|
||||||
|
final bodyGrowth = press * 12 + pulse * 7 + sway.abs() * 4;
|
||||||
|
final bodyHeight = _height + press * 5 + pulse * 3 - sway.abs() * 0.8;
|
||||||
|
final dockShift = sway * 8;
|
||||||
|
final leadingPull = sway * 6;
|
||||||
|
final lensLeft =
|
||||||
|
(_inset + _position * _travel - (lensWidth - _travel) / 2).clamp(
|
||||||
|
-edgeAllowance - dockShift,
|
||||||
|
_width + edgeAllowance - dockShift - lensWidth,
|
||||||
|
);
|
||||||
|
final symbolRefraction = _reduceMotion
|
||||||
|
? 0.0
|
||||||
|
: math.max(press.clamp(0.0, 1.0), _tapBloom);
|
||||||
|
return SizedBox(
|
||||||
|
width: _width,
|
||||||
|
height: _height,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
left: dockShift,
|
||||||
|
top: 0,
|
||||||
|
width: _width,
|
||||||
|
height: _height,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(40),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF4C6078)
|
||||||
|
.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 14,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
left: -bodyGrowth / 2 + math.min(leadingPull, 0),
|
||||||
|
top: (_height - bodyHeight) / 2,
|
||||||
|
width: _width + bodyGrowth + leadingPull.abs(),
|
||||||
|
height: bodyHeight,
|
||||||
|
child: _glass(child: const SizedBox.expand()),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: lensLeft,
|
||||||
|
top: (_height - lensHeight) / 2,
|
||||||
|
width: lensWidth,
|
||||||
|
height: lensHeight,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: _glass(
|
||||||
|
lens: true,
|
||||||
|
child: const SizedBox.expand(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: _inset,
|
||||||
|
right: _inset,
|
||||||
|
top: _inset,
|
||||||
|
bottom: _inset,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_item(0, Icons.calendar_month_rounded, '课表'),
|
||||||
|
_item(1, Icons.assignment_rounded, '考试'),
|
||||||
|
_item(
|
||||||
|
2,
|
||||||
|
Icons.person_rounded,
|
||||||
|
'个人设置',
|
||||||
|
caption: '我的',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (symbolRefraction > 0.005)
|
||||||
|
Positioned(
|
||||||
|
key: const ValueKey('dock-symbol-refraction'),
|
||||||
|
left: lensLeft,
|
||||||
|
top: (_height - lensHeight) / 2,
|
||||||
|
width: lensWidth,
|
||||||
|
height: lensHeight,
|
||||||
|
// This optical pass samples the already-painted
|
||||||
|
// symbols too. It fades out with pressure; the
|
||||||
|
// stationary symbols remain sharp and tappable.
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(40),
|
||||||
|
child: GlassRefraction(
|
||||||
|
strength: symbolRefraction * 0.52,
|
||||||
|
blurSigma: 0,
|
||||||
|
child: const SizedBox.expand(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
Positioned(
|
),
|
||||||
left: _inset,
|
);
|
||||||
right: _inset,
|
},
|
||||||
top: _inset,
|
|
||||||
bottom: _inset,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
_item(0, Icons.calendar_month_rounded, '课表'),
|
|
||||||
_item(1, Icons.assignment_rounded, '考试'),
|
|
||||||
_item(2, Icons.person_rounded, '个人设置', caption: '我的'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -239,9 +486,12 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
|
|||||||
excludeSemantics: true,
|
excludeSemantics: true,
|
||||||
onTap: () => _select(index),
|
onTap: () => _select(index),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
key: index == widget.selectedIndex ? const Key('dock-cursor') : null,
|
// Selection must not replace the symbol's active animation state.
|
||||||
|
key: ValueKey('dock-item-$index'),
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTapDown: index == widget.selectedIndex ? (_) => _pressTo(1) : null,
|
onTapDown: index == widget.selectedIndex
|
||||||
|
? (_) => _pressTo(_reduceMotion ? 0 : 1)
|
||||||
|
: null,
|
||||||
onTapCancel: () {
|
onTapCancel: () {
|
||||||
if (_drag == null) _pressTo(0);
|
if (_drag == null) _pressTo(0);
|
||||||
},
|
},
|
||||||
@@ -249,8 +499,12 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
|
|||||||
onHorizontalDragStart: index == widget.selectedIndex
|
onHorizontalDragStart: index == widget.selectedIndex
|
||||||
? (_) {
|
? (_) {
|
||||||
final start = _position.clamp(0.0, 2.0);
|
final start = _position.clamp(0.0, 2.0);
|
||||||
|
_dragFollow.value = start;
|
||||||
|
_dragTarget = start;
|
||||||
|
_dragVelocity = _motion.velocity;
|
||||||
_motion.stop();
|
_motion.stop();
|
||||||
_pressTo(1);
|
_wakeRipple();
|
||||||
|
_pressTo(_reduceMotion ? 0 : 1);
|
||||||
setState(() {
|
setState(() {
|
||||||
_drag = start;
|
_drag = start;
|
||||||
_dragSpeed = 0;
|
_dragSpeed = 0;
|
||||||
@@ -260,17 +514,17 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
|
|||||||
onHorizontalDragUpdate: index == widget.selectedIndex
|
onHorizontalDragUpdate: index == widget.selectedIndex
|
||||||
? (details) => setState(() {
|
? (details) => setState(() {
|
||||||
_dragSpeed = details.delta.dx / _travel * 60;
|
_dragSpeed = details.delta.dx / _travel * 60;
|
||||||
_drag = (_position + details.delta.dx / _travel).clamp(
|
_wakeRipple();
|
||||||
0.0,
|
_swayTo((_dragSpeed / 8).clamp(-1.0, 1.0));
|
||||||
2.0,
|
_drag = (_drag! + details.delta.dx / _travel).clamp(0.0, 2.0);
|
||||||
);
|
_followDragTo(_drag!);
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
onHorizontalDragEnd: index == widget.selectedIndex
|
onHorizontalDragEnd: index == widget.selectedIndex
|
||||||
? (details) => _select(
|
? (details) => _select(
|
||||||
_position.round().clamp(0, 2),
|
(_drag ?? _position).round().clamp(0, 2),
|
||||||
elastic: true,
|
elastic: true,
|
||||||
velocity: details.velocity.pixelsPerSecond.dx / _travel,
|
velocity: _dragVelocity,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onHorizontalDragCancel: index == widget.selectedIndex
|
onHorizontalDragCancel: index == widget.selectedIndex
|
||||||
@@ -279,8 +533,9 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
|
|||||||
final position = _position;
|
final position = _position;
|
||||||
setState(() => _drag = null);
|
setState(() => _drag = null);
|
||||||
_motion.value = position;
|
_motion.value = position;
|
||||||
_moveTo(widget.selectedIndex, elastic: true);
|
_moveTo(widget.selectedIndex, velocity: _dragVelocity);
|
||||||
_pressTo(0);
|
_pressTo(0);
|
||||||
|
_swayTo(0);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -292,38 +547,43 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
|
|||||||
? 1 + _pressure * 0.08
|
? 1 + _pressure * 0.08
|
||||||
: 1,
|
: 1,
|
||||||
duration: const Duration(milliseconds: 130),
|
duration: const Duration(milliseconds: 130),
|
||||||
child: Icon(
|
child: _DockSymbol(
|
||||||
icon,
|
active: (_position - index).abs() < 0.28,
|
||||||
size: 25,
|
reduceMotion: _reduceMotion,
|
||||||
color: index == _position.round().clamp(0, 2)
|
child: Icon(
|
||||||
? _blue
|
icon,
|
||||||
: _blue.withValues(alpha: 0.72),
|
key: index == widget.selectedIndex
|
||||||
shadows: [
|
? const Key('dock-cursor')
|
||||||
Shadow(
|
: null,
|
||||||
color: Colors.white.withValues(
|
size: 25,
|
||||||
alpha: index == _position.round().clamp(0, 2)
|
color: index == widget.selectedIndex ? _blue : _inactive,
|
||||||
? 0.60
|
shadows: [
|
||||||
: 0.38,
|
Shadow(
|
||||||
|
color: Colors.white.withValues(
|
||||||
|
alpha: index == _position.round().clamp(0, 2)
|
||||||
|
? 0.60
|
||||||
|
: 0.38,
|
||||||
|
),
|
||||||
|
blurRadius: 8,
|
||||||
),
|
),
|
||||||
blurRadius: 8,
|
Shadow(
|
||||||
),
|
color: Colors.white.withValues(
|
||||||
Shadow(
|
alpha: index == _position.round().clamp(0, 2)
|
||||||
color: Colors.white.withValues(
|
? 0.95
|
||||||
alpha: index == _position.round().clamp(0, 2)
|
: 0.85,
|
||||||
? 0.95
|
),
|
||||||
: 0.85,
|
blurRadius: 2.8,
|
||||||
),
|
),
|
||||||
blurRadius: 2.8,
|
Shadow(
|
||||||
),
|
color: Colors.white.withValues(
|
||||||
Shadow(
|
alpha: index == _position.round().clamp(0, 2)
|
||||||
color: Colors.white.withValues(
|
? 0.34
|
||||||
alpha: index == _position.round().clamp(0, 2)
|
: 0.22,
|
||||||
? 0.34
|
),
|
||||||
: 0.22,
|
blurRadius: 1.4,
|
||||||
),
|
),
|
||||||
blurRadius: 1.4,
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
@@ -337,9 +597,7 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
|
|||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
height: 1.1,
|
height: 1.1,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: index == _position.round().clamp(0, 2)
|
color: index == widget.selectedIndex ? _blue : _inactive,
|
||||||
? _blue
|
|
||||||
: _blue.withValues(alpha: 0.62),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -352,15 +610,66 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A diffuse silver bevel and thin directional glints show optical thickness.
|
/// Plays once on entry. Remaining over a symbol does not restart its animation.
|
||||||
/// The painted highlights stay on the edge so background colour passes through.
|
class _DockSymbol extends StatefulWidget {
|
||||||
|
const _DockSymbol({
|
||||||
|
required this.active,
|
||||||
|
required this.reduceMotion,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
final bool active, reduceMotion;
|
||||||
|
final Widget child;
|
||||||
|
@override
|
||||||
|
State<_DockSymbol> createState() => _DockSymbolState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DockSymbolState extends State<_DockSymbol>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _bounce = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 440),
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(_DockSymbol oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.reduceMotion || !widget.active) {
|
||||||
|
_bounce.value = 0;
|
||||||
|
} else if (!oldWidget.active) {
|
||||||
|
_bounce.forward(from: 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_bounce.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => AnimatedBuilder(
|
||||||
|
animation: _bounce,
|
||||||
|
child: widget.child,
|
||||||
|
builder: (context, child) {
|
||||||
|
final lift = widget.reduceMotion
|
||||||
|
? 0.0
|
||||||
|
: math.sin(math.pi * _bounce.value);
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(0, -3.5 * lift),
|
||||||
|
child: Transform.scale(scale: 1 + lift * 0.16, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A white, rounded bevel catches light around a translucent milky body.
|
||||||
|
/// Highlights stay at the edge; the backdrop remains visible beneath the glass.
|
||||||
class _GlassRimPainter extends CustomPainter {
|
class _GlassRimPainter extends CustomPainter {
|
||||||
const _GlassRimPainter({required this.lens, required this.pressure});
|
const _GlassRimPainter({required this.lens, required this.pressure});
|
||||||
final bool lens;
|
final bool lens;
|
||||||
final double pressure;
|
final double pressure;
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
final edge = lens ? const Color(0xFF7B838F) : const Color(0xFFAFBECF);
|
final edge = lens ? const Color(0xFF7B838F) : const Color(0xFF9CAABD);
|
||||||
// A quiet grey selection at rest; the glossy lens rim returns on touch.
|
// A quiet grey selection at rest; the glossy lens rim returns on touch.
|
||||||
final opacity = lens ? 0.25 + pressure * 0.75 : 1.0;
|
final opacity = lens ? 0.25 + pressure * 0.75 : 1.0;
|
||||||
final bounds = Offset.zero & size;
|
final bounds = Offset.zero & size;
|
||||||
@@ -376,15 +685,15 @@ class _GlassRimPainter extends CustomPainter {
|
|||||||
outer,
|
outer,
|
||||||
Paint()
|
Paint()
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = lens ? 1.8 : 2.2
|
..strokeWidth = lens ? 1.2 : 1.6
|
||||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, lens ? 0.9 : 0.45)
|
..maskFilter = MaskFilter.blur(BlurStyle.normal, lens ? 1.05 : 1.0)
|
||||||
..shader = ui.Gradient.linear(
|
..shader = ui.Gradient.linear(
|
||||||
bounds.topLeft,
|
bounds.topLeft,
|
||||||
bounds.bottomRight,
|
bounds.bottomRight,
|
||||||
[
|
[
|
||||||
Colors.white.withValues(alpha: 0.24 * opacity),
|
Colors.white.withValues(alpha: (lens ? 0.24 : 0.42) * opacity),
|
||||||
edge.withValues(alpha: 0.04 * opacity),
|
edge.withValues(alpha: 0.04 * opacity),
|
||||||
Colors.white.withValues(alpha: 0.14 * opacity),
|
Colors.white.withValues(alpha: (lens ? 0.14 : 0.30) * opacity),
|
||||||
],
|
],
|
||||||
[0.0, 0.55, 1.0],
|
[0.0, 0.55, 1.0],
|
||||||
),
|
),
|
||||||
@@ -393,16 +702,16 @@ class _GlassRimPainter extends CustomPainter {
|
|||||||
outer,
|
outer,
|
||||||
Paint()
|
Paint()
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = lens ? 1.25 : 1.1
|
..strokeWidth = lens ? 0.85 : 0.65
|
||||||
..shader = ui.Gradient.sweep(
|
..shader = ui.Gradient.sweep(
|
||||||
bounds.center,
|
bounds.center,
|
||||||
[
|
[
|
||||||
Colors.white.withValues(alpha: 0.96 * opacity),
|
Colors.white.withValues(alpha: 0.84 * opacity),
|
||||||
edge.withValues(alpha: (lens ? 0.30 : 0.15) * opacity),
|
edge.withValues(alpha: (lens ? 0.30 : 0.15) * opacity),
|
||||||
Colors.white.withValues(alpha: 0.10 * opacity),
|
Colors.white.withValues(alpha: 0.10 * opacity),
|
||||||
Colors.white.withValues(alpha: 0.94 * opacity),
|
Colors.white.withValues(alpha: 0.82 * opacity),
|
||||||
edge.withValues(alpha: 0.12 * opacity),
|
edge.withValues(alpha: 0.12 * opacity),
|
||||||
Colors.white.withValues(alpha: 0.96 * opacity),
|
Colors.white.withValues(alpha: 0.84 * opacity),
|
||||||
],
|
],
|
||||||
[0.0, 0.18, 0.38, 0.60, 0.80, 1.0],
|
[0.0, 0.18, 0.38, 0.60, 0.80, 1.0],
|
||||||
TileMode.clamp,
|
TileMode.clamp,
|
||||||
@@ -414,14 +723,14 @@ class _GlassRimPainter extends CustomPainter {
|
|||||||
inner,
|
inner,
|
||||||
Paint()
|
Paint()
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = 0.7
|
..strokeWidth = 0.35
|
||||||
..shader = ui.Gradient.linear(
|
..shader = ui.Gradient.linear(
|
||||||
bounds.topLeft,
|
bounds.topLeft,
|
||||||
bounds.bottomRight,
|
bounds.bottomRight,
|
||||||
[
|
[
|
||||||
Colors.white.withValues(alpha: 0.20 * opacity),
|
Colors.white.withValues(alpha: (lens ? 0.20 : 0.34) * opacity),
|
||||||
edge.withValues(alpha: (lens ? 0.13 : 0.07) * opacity),
|
edge.withValues(alpha: (lens ? 0.13 : 0.07) * opacity),
|
||||||
Colors.white.withValues(alpha: 0.28 * opacity),
|
Colors.white.withValues(alpha: (lens ? 0.28 : 0.40) * opacity),
|
||||||
],
|
],
|
||||||
[0.0, 0.5, 1.0],
|
[0.0, 0.5, 1.0],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class TabDeck extends StatefulWidget {
|
|||||||
class _TabDeckState extends State<TabDeck> with SingleTickerProviderStateMixin {
|
class _TabDeckState extends State<TabDeck> with SingleTickerProviderStateMixin {
|
||||||
late final AnimationController _motion = AnimationController(
|
late final AnimationController _motion = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 330),
|
duration: const Duration(milliseconds: 240),
|
||||||
value: 1,
|
value: 1,
|
||||||
);
|
);
|
||||||
int? _outgoing;
|
int? _outgoing;
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: hepan_everyday
|
name: hepan_everyday
|
||||||
description: TPSchedule 学生课表
|
description: TPSchedule 学生课表
|
||||||
publish_to: none
|
publish_to: none
|
||||||
version: 0.7.8+26
|
version: 0.7.10+36
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.13.5
|
sdk: ^3.13.5
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ void main() {
|
|||||||
float distance = length(outward);
|
float distance = length(outward);
|
||||||
vec2 normal = outward / max(distance, 0.001);
|
vec2 normal = outward / max(distance, 0.001);
|
||||||
float depth = radius - distance;
|
float depth = radius - distance;
|
||||||
float rim = 1.0 - smoothstep(0.0, radius * 0.46, depth);
|
float rim = 1.0 - smoothstep(0.0, radius * 0.52, depth);
|
||||||
// Curved glass bends rays most at its edge, with subtle centre magnification.
|
// Curved glass bends rays most at its edge, with subtle centre magnification.
|
||||||
vec2 ray = centre + local / (1.0 + u_strength * 0.075)
|
vec2 ray = centre + local / (1.0 + u_strength * 0.075)
|
||||||
- normal * rim * rim * radius * u_strength * 0.20;
|
- normal * rim * rim * radius * u_strength * 0.23;
|
||||||
vec4 colour = texture(u_background, sampleUV(ray));
|
vec4 colour = texture(u_background, sampleUV(ray));
|
||||||
float dispersion = u_strength * rim * 0.65;
|
float dispersion = u_strength * rim * 0.80;
|
||||||
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;
|
||||||
// The curved rim catches nearby background light as well as transmitting it.
|
// The curved rim catches nearby background light as well as transmitting it.
|
||||||
@@ -40,8 +40,11 @@ void main() {
|
|||||||
float edge_light = pow(rim, 4.0) * u_strength * facing;
|
float edge_light = pow(rim, 4.0) * u_strength * facing;
|
||||||
vec4 reflected = texture(u_background,
|
vec4 reflected = texture(u_background,
|
||||||
sampleUV(point + normal * radius * 0.40));
|
sampleUV(point + normal * radius * 0.40));
|
||||||
colour = mix(colour, reflected, min(edge_light * 0.18, 0.22));
|
colour = mix(colour, reflected, min(edge_light * 0.22, 0.26));
|
||||||
float reflection = edge_light * 0.07;
|
// A grazing-angle white reflection gives the curved rim visible thickness;
|
||||||
|
// the broad centre continues to transmit the live, lightly softened scene.
|
||||||
|
colour.rgb = mix(colour.rgb, vec3(colour.a), min(edge_light * 0.19, 0.22));
|
||||||
|
float reflection = edge_light * 0.10;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,31 @@ String fixture() {
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
final url = Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1');
|
final url = Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1');
|
||||||
|
test('graduate course identifiers need not be numeric and missing rooms remain explicit', () {
|
||||||
|
final body = fixture()
|
||||||
|
.replaceAll('101/示例', 'G101/示例')
|
||||||
|
.replaceAll('/示例楼B212', '/');
|
||||||
|
final draft = SchoolPageAdapter().extract(body, url);
|
||||||
|
expect(draft.courses, hasLength(4));
|
||||||
|
final pending = draft.courses.where((c) => c.name.contains('集成电路'));
|
||||||
|
expect(pending.every((c) => c.room == '教室待定'), isTrue);
|
||||||
|
expect(pending.every((c) => c.notes.contains('学校未提供教室')), isTrue);
|
||||||
|
});
|
||||||
|
test('graduate malformed record provides a safe specific reason', () {
|
||||||
|
expect(
|
||||||
|
() => SchoolPageAdapter().extract(
|
||||||
|
fixture().replaceFirst('1-3周', '未知周'),
|
||||||
|
url,
|
||||||
|
),
|
||||||
|
throwsA(
|
||||||
|
isA<UnsupportedSchoolLayout>().having(
|
||||||
|
(e) => e.toString(),
|
||||||
|
'reason',
|
||||||
|
contains('上课周次'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
test('school DOM merges week segments without losing full metadata or evening periods', () {
|
test('school DOM merges week segments without losing full metadata or evening periods', () {
|
||||||
final draft = SchoolPageAdapter().extract(fixture(), url);
|
final draft = SchoolPageAdapter().extract(fixture(), url);
|
||||||
expect(draft.courses.length, 4);
|
expect(draft.courses.length, 4);
|
||||||
|
|||||||
@@ -31,6 +31,59 @@ void main() {
|
|||||||
platform.delegate.finished(url);
|
platform.delegate.finished(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'graduate parser rejection keeps its reason instead of becoming a network timeout',
|
||||||
|
(tester) async {
|
||||||
|
addTearDown(SchoolPageDiagnostic.instance.clear);
|
||||||
|
session.connectionState = SchoolConnectionState.connected;
|
||||||
|
await session.openTimetable(term: SchoolTerm(73));
|
||||||
|
platform.web.javascript = (_) async => schoolPageEvidence(
|
||||||
|
topUrl: platform.web.url!,
|
||||||
|
html: fixture().replaceFirst('1-3周', '未知周'),
|
||||||
|
);
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
navigate(SchoolTerm(73).timetableUri.toString());
|
||||||
|
await flushSchoolEvents();
|
||||||
|
});
|
||||||
|
expect(session.message, contains('上课周次'));
|
||||||
|
await tester.pump(const Duration(seconds: 46));
|
||||||
|
expect(session.message, contains('上课周次'));
|
||||||
|
expect(session.message, isNot(contains('查找超时')));
|
||||||
|
expect(session.pendingDraft, isNull);
|
||||||
|
final log = utf8.decode(await DiagnosticLog.instance.export());
|
||||||
|
expect(log, contains('"code":"weeks_invalid"'));
|
||||||
|
expect(log, contains('"column":5'));
|
||||||
|
expect(log, isNot(contains('示例集成电路')));
|
||||||
|
SchoolPageDiagnostic.instance.clear();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
testWidgets('a new graduate page never inherits an old parsing reason', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
addTearDown(SchoolPageDiagnostic.instance.clear);
|
||||||
|
session.connectionState = SchoolConnectionState.connected;
|
||||||
|
await session.openTimetable(term: SchoolTerm(73));
|
||||||
|
platform.web.javascript = (_) async => schoolPageEvidence(
|
||||||
|
topUrl: platform.web.url!,
|
||||||
|
html: fixture().replaceFirst('1-3周', '未知周'),
|
||||||
|
);
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
navigate(SchoolTerm(73).timetableUri.toString());
|
||||||
|
await flushSchoolEvents();
|
||||||
|
});
|
||||||
|
expect(session.message, contains('上课周次'));
|
||||||
|
platform.web.javascript = (_) async =>
|
||||||
|
schoolPageEvidence(topUrl: platform.web.url!);
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
navigate(SchoolTerm(72).timetableUri.toString());
|
||||||
|
await flushSchoolEvents();
|
||||||
|
});
|
||||||
|
await tester.pump(const Duration(seconds: 46));
|
||||||
|
expect(session.message, contains('查找超时'));
|
||||||
|
expect(session.message, isNot(contains('上课周次')));
|
||||||
|
SchoolPageDiagnostic.instance.clear();
|
||||||
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'graduate auto read retains HTML after leaving and clears on next query',
|
'graduate auto read retains HTML after leaving and clears on next query',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:hepan_everyday/features/school/undergraduate/timetable_adapter.dart';
|
import 'package:hepan_everyday/features/school/undergraduate/timetable_adapter.dart';
|
||||||
import 'package:hepan_everyday/data/schedule_file_import.dart';
|
import 'package:hepan_everyday/data/schedule_file_import.dart';
|
||||||
import 'package:hepan_everyday/domain/school_identity.dart';
|
import 'package:hepan_everyday/domain/school_identity.dart';
|
||||||
|
import 'package:hepan_everyday/domain/course.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('missing course fields identify the failing field without disclosing values', () {
|
test('missing course fields identify the failing field without disclosing values', () {
|
||||||
@@ -15,7 +16,6 @@ void main() {
|
|||||||
'index=0*unitCount+0;table0.activities[index][table0.activities[index].length]=activity;';
|
'index=0*unitCount+0;table0.activities[index][table0.activities[index].length]=activity;';
|
||||||
for (final entry in {
|
for (final entry in {
|
||||||
'teacher_missing': base.replaceFirst('"教师"', '""'),
|
'teacher_missing': base.replaceFirst('"教师"', '""'),
|
||||||
'room_missing': base.replaceFirst('"教室"', '""'),
|
|
||||||
'course_sequence_invalid': base.replaceFirst('课程(C.01)', '课程'),
|
'course_sequence_invalid': base.replaceFirst('课程(C.01)', '课程'),
|
||||||
}.entries) {
|
}.entries) {
|
||||||
expect(
|
expect(
|
||||||
@@ -51,6 +51,24 @@ void main() {
|
|||||||
String fixture() =>
|
String fixture() =>
|
||||||
File('test/fixtures/school/undergraduate-timetable.html')
|
File('test/fixtures/school/undergraduate-timetable.html')
|
||||||
.readAsStringSync();
|
.readAsStringSync();
|
||||||
|
test('missing room retains every scheduled occurrence and clearly marks it pending', () {
|
||||||
|
final original = UndergraduateTimetableAdapter.extract(fixture(), uri);
|
||||||
|
final draft = UndergraduateTimetableAdapter.extract(
|
||||||
|
fixture().replaceFirst('"教室1"', '""'),
|
||||||
|
uri,
|
||||||
|
);
|
||||||
|
Set<String> occurrences(Iterable<Course> courses) => <String>{
|
||||||
|
for (final c in courses)
|
||||||
|
for (final w in c.weeks)
|
||||||
|
for (var p = c.startPeriod; p <= c.endPeriod; p++)
|
||||||
|
'${c.courseCode}/${c.teacher}/${c.weekday}/$p/$w',
|
||||||
|
};
|
||||||
|
expect(occurrences(draft.courses), occurrences(original.courses));
|
||||||
|
final pending = draft.courses.where((c) => c.room == '教室待定');
|
||||||
|
expect(pending, isNotEmpty);
|
||||||
|
expect(pending.every((c) => c.notes.contains('学校未提供教室')), isTrue);
|
||||||
|
expect(draft.courses.any((c) => c.room == '教室2'), isTrue);
|
||||||
|
});
|
||||||
test('undergraduate activity data preserves teachers rooms weeks and period twelve', () {
|
test('undergraduate activity data preserves teachers rooms weeks and period twelve', () {
|
||||||
final draft = UndergraduateTimetableAdapter.extract(fixture(), uri);
|
final draft = UndergraduateTimetableAdapter.extract(fixture(), uri);
|
||||||
expect(draft.schoolTerm!.id, 523);
|
expect(draft.schoolTerm!.id, 523);
|
||||||
|
|||||||
@@ -7,6 +7,87 @@ import 'package:hepan_everyday/services/local_notification_service.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
test(
|
||||||
|
'system handle audit only accepts IDs from the requested cache',
|
||||||
|
() async {
|
||||||
|
const system = MethodChannel('com.tpschedule.app/reminders');
|
||||||
|
final messenger =
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
|
messenger.setMockMethodCallHandler(system, (call) async {
|
||||||
|
expect(call.method, 'findMissingAlarmHandles');
|
||||||
|
expect((call.arguments as List).toSet(), {11, 22});
|
||||||
|
return [22, 999];
|
||||||
|
});
|
||||||
|
addTearDown(() => messenger.setMockMethodCallHandler(system, null));
|
||||||
|
expect(await PluginReminderGateway().missingSystemIds({11, 22}), {22});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'optional handle audit failure does not prevent reconciliation',
|
||||||
|
() async {
|
||||||
|
const system = MethodChannel('com.tpschedule.app/reminders');
|
||||||
|
final messenger =
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
|
var calls = 0;
|
||||||
|
messenger.setMockMethodCallHandler(system, (_) async {
|
||||||
|
calls++;
|
||||||
|
throw PlatformException(code: 'reminder_system_unavailable');
|
||||||
|
});
|
||||||
|
addTearDown(() => messenger.setMockMethodCallHandler(system, null));
|
||||||
|
final gateway = PluginReminderGateway();
|
||||||
|
expect(await gateway.missingSystemIds({11}), isEmpty);
|
||||||
|
expect(await gateway.missingSystemIds({}), isEmpty);
|
||||||
|
expect(calls, 1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test('fresh notification initialization creates the course category before checks', () async {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||||
|
AndroidFlutterLocalNotificationsPlugin.registerWith();
|
||||||
|
const channel = MethodChannel('dexterous.com/flutter/local_notifications');
|
||||||
|
const system = MethodChannel('com.tpschedule.app/reminders');
|
||||||
|
final messenger =
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
|
final categories = <Map<Object?, Object?>>[];
|
||||||
|
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
switch (call.method) {
|
||||||
|
case 'initialize':
|
||||||
|
return true;
|
||||||
|
case 'createNotificationChannel':
|
||||||
|
final data = call.arguments as Map;
|
||||||
|
categories.add({
|
||||||
|
...Map<Object?, Object?>.from(data),
|
||||||
|
// Android returns a numeric color even when no LED was set.
|
||||||
|
'ledColor': data['ledColor'] ?? 0,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
case 'getNotificationAppLaunchDetails':
|
||||||
|
return {'notificationLaunchedApp': false};
|
||||||
|
case 'areNotificationsEnabled':
|
||||||
|
case 'canScheduleExactNotifications':
|
||||||
|
return true;
|
||||||
|
case 'getNotificationChannels':
|
||||||
|
return categories;
|
||||||
|
case 'pendingNotificationRequests':
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
messenger.setMockMethodCallHandler(
|
||||||
|
system,
|
||||||
|
(_) async => <Object?, Object?>{},
|
||||||
|
);
|
||||||
|
addTearDown(() {
|
||||||
|
debugDefaultTargetPlatformOverride = null;
|
||||||
|
messenger.setMockMethodCallHandler(channel, null);
|
||||||
|
messenger.setMockMethodCallHandler(system, null);
|
||||||
|
});
|
||||||
|
final gateway = PluginReminderGateway();
|
||||||
|
final diagnosis = await gateway.diagnose();
|
||||||
|
expect(diagnosis['channelImportance'], Importance.high.value);
|
||||||
|
expect(diagnosis['appNotificationsEnabled'], isTrue);
|
||||||
|
expect(diagnosis['channelExists'], isTrue);
|
||||||
|
expect(categories.single['id'], 'hepan_lessons');
|
||||||
|
});
|
||||||
test(
|
test(
|
||||||
'querying future reminders does not depend on active notification access',
|
'querying future reminders does not depend on active notification access',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ class AlarmGateway extends ReminderGateway {
|
|||||||
final alarms = <int, ReminderRequest>{};
|
final alarms = <int, ReminderRequest>{};
|
||||||
int permissionRequests = 0;
|
int permissionRequests = 0;
|
||||||
bool dropWrites = false;
|
bool dropWrites = false;
|
||||||
|
final missingHandles = <int>{};
|
||||||
|
Set<int> lastAuditedIds = {};
|
||||||
|
int scheduleCalls = 0;
|
||||||
|
@override
|
||||||
|
Future<Set<int>> missingSystemIds(Set<int> ids) async {
|
||||||
|
lastAuditedIds = {...ids};
|
||||||
|
return missingHandles.intersection(ids);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ReminderPermissions> permissions({required bool request}) async {
|
Future<ReminderPermissions> permissions({required bool request}) async {
|
||||||
if (request) permissionRequests++;
|
if (request) permissionRequests++;
|
||||||
@@ -23,7 +32,9 @@ class AlarmGateway extends ReminderGateway {
|
|||||||
Future<Set<int>> pendingIds() async => alarms.keys.toSet();
|
Future<Set<int>> pendingIds() async => alarms.keys.toSet();
|
||||||
@override
|
@override
|
||||||
Future<void> schedule(ReminderRequest value) async {
|
Future<void> schedule(ReminderRequest value) async {
|
||||||
|
scheduleCalls++;
|
||||||
if (!dropWrites) alarms[value.id] = value;
|
if (!dropWrites) alarms[value.id] = value;
|
||||||
|
missingHandles.remove(value.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -48,6 +59,32 @@ void main() {
|
|||||||
return AppSnapshot.fromJson(json).settings;
|
return AppSnapshot.fromJson(json).settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
'overdue cached reminders are audited before removal on warm resume',
|
||||||
|
() async {
|
||||||
|
final events = ScheduleEngine.occurrences([course()], term);
|
||||||
|
await service.reconcile(events, settings, DateTime.utc(2026, 9, 9));
|
||||||
|
final cached = gateway.alarms.keys.toSet();
|
||||||
|
expect(cached, isNotEmpty);
|
||||||
|
await service.reconcile(events, settings, DateTime.utc(2027, 1, 1));
|
||||||
|
expect(gateway.lastAuditedIds, cached);
|
||||||
|
expect(gateway.alarms, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('cached fingerprint does not hide a missing system trigger', () async {
|
||||||
|
final events = ScheduleEngine.occurrences([course()], term);
|
||||||
|
final now = DateTime.utc(2026, 9, 9);
|
||||||
|
await service.reconcile(events, settings, now);
|
||||||
|
final previous = gateway.scheduleCalls;
|
||||||
|
gateway.missingHandles.addAll(gateway.alarms.keys);
|
||||||
|
await service.reconcile(events, settings, now);
|
||||||
|
expect(gateway.scheduleCalls, previous + 1);
|
||||||
|
expect(gateway.missingHandles, isEmpty);
|
||||||
|
await service.reconcile(events, settings, now);
|
||||||
|
expect(gateway.scheduleCalls, previous + 1);
|
||||||
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'does not report success when native scheduling did not persist',
|
'does not report success when native scheduling did not persist',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:timezone/data/latest.dart' as tzdata;
|
import 'package:timezone/data/latest.dart' as tzdata;
|
||||||
@@ -9,6 +11,162 @@ import 'navigation_test.dart' show uiController;
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
setUpAll(tzdata.initializeTimeZones);
|
setUpAll(tzdata.initializeTimeZones);
|
||||||
|
testWidgets(
|
||||||
|
'tap reaches its page promptly and finishes the glass pulse within 480ms',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(_dock());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final lens = find.byType(GlassRefraction).last;
|
||||||
|
final rest = tester.getSize(lens);
|
||||||
|
final target = tester.getCenter(find.byIcon(Icons.assignment_rounded)).dx;
|
||||||
|
await tester.tap(find.byIcon(Icons.assignment_rounded));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 160));
|
||||||
|
expect((tester.getCenter(lens).dx - target).abs(), lessThan(7));
|
||||||
|
for (var i = 0; i < 20; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('dock-symbol-refraction')),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
expect(tester.getSize(lens).width, closeTo(rest.width, 0.5));
|
||||||
|
expect(tester.getCenter(lens).dx, closeTo(target, 0.6));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
testWidgets(
|
||||||
|
'moving lens refracts symbols above the icon row and releases cleanly',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(_dock());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final optical = find.byKey(const ValueKey('dock-symbol-refraction'));
|
||||||
|
expect(optical, findsNothing);
|
||||||
|
final gesture = await tester.startGesture(
|
||||||
|
tester.getCenter(find.byKey(const Key('dock-cursor'))),
|
||||||
|
);
|
||||||
|
await gesture.moveBy(const Offset(24, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.moveBy(const Offset(54, 0));
|
||||||
|
await tester.pump(const Duration(milliseconds: 120));
|
||||||
|
expect(optical, findsOneWidget);
|
||||||
|
final filter = tester.widget<GlassRefraction>(
|
||||||
|
find.descendant(of: optical, matching: find.byType(GlassRefraction)),
|
||||||
|
);
|
||||||
|
expect(filter.strength, greaterThan(0.35));
|
||||||
|
expect(filter.blurSigma, 0); // Keep the transmitted symbols legible.
|
||||||
|
final stack = tester.widget<Stack>(
|
||||||
|
find.ancestor(of: optical, matching: find.byType(Stack)).first,
|
||||||
|
);
|
||||||
|
expect(stack.children.last.key, const ValueKey('dock-symbol-refraction'));
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(optical, findsNothing);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
testWidgets('edge overflow respects the actual dock parent and safe insets', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(320, 800);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
var selected = 0;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: MediaQuery(
|
||||||
|
data: const MediaQueryData(size: Size(320, 800)),
|
||||||
|
child: StatefulBuilder(
|
||||||
|
builder: (context, setState) => Scaffold(
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 30),
|
||||||
|
child: Center(
|
||||||
|
child: GlassDock(
|
||||||
|
selectedIndex: selected,
|
||||||
|
onSelected: (i) => setState(() => selected = i),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byIcon(Icons.person_rounded));
|
||||||
|
await tester.pump();
|
||||||
|
for (var i = 0; i < 70; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
expect(
|
||||||
|
tester.getRect(find.byType(GlassRefraction).last).right,
|
||||||
|
lessThanOrEqualTo(312.5),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
testWidgets(
|
||||||
|
'tap pulls the dock along and enlarged edge lens can overflow its body',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(320, 800);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
await tester.pumpWidget(_dock());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final dock = find.byType(GlassDock),
|
||||||
|
lens = find.byType(GlassRefraction).last;
|
||||||
|
final icon = find.byIcon(Icons.calendar_month_rounded);
|
||||||
|
final x = tester.getCenter(icon).dx;
|
||||||
|
var pulled = x, overflow = 0.0;
|
||||||
|
await tester.tap(find.byIcon(Icons.person_rounded));
|
||||||
|
await tester.pump();
|
||||||
|
for (var i = 0; i < 70; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
pulled = math.max(pulled, tester.getCenter(icon).dx);
|
||||||
|
final r = tester.getRect(lens);
|
||||||
|
overflow = math.max(overflow, r.right - tester.getRect(dock).right);
|
||||||
|
expect(r.left, greaterThanOrEqualTo(7.5));
|
||||||
|
expect(r.right, lessThanOrEqualTo(312.5));
|
||||||
|
}
|
||||||
|
expect(pulled, greaterThan(x + 3));
|
||||||
|
expect(overflow, greaterThan(5));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.getCenter(icon).dx, closeTo(x, 0.1));
|
||||||
|
expect(tester.getRect(lens).right, lessThan(tester.getRect(dock).right));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
testWidgets('drag tugs the leading edge and lens follows with elastic lag', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(_dock());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byIcon(Icons.assignment_rounded));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final lens = find.byType(GlassRefraction).last;
|
||||||
|
final body = find.byType(GlassRefraction).first;
|
||||||
|
final gesture = await tester.startGesture(
|
||||||
|
tester.getCenter(find.byKey(const Key('dock-cursor'))),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 110));
|
||||||
|
await tester.pump(const Duration(milliseconds: 700));
|
||||||
|
await gesture.moveBy(const Offset(24, 0));
|
||||||
|
await tester.pump();
|
||||||
|
final initialX = tester.getCenter(lens).dx;
|
||||||
|
final initialBody = tester.getRect(body);
|
||||||
|
await gesture.moveBy(const Offset(60, 0));
|
||||||
|
await tester.pump();
|
||||||
|
expect(tester.getCenter(lens).dx - initialX, lessThan(45));
|
||||||
|
await tester.pump(const Duration(milliseconds: 180));
|
||||||
|
expect(tester.getCenter(lens).dx, greaterThan(initialX + 35));
|
||||||
|
final draggedBody = tester.getRect(body);
|
||||||
|
final leading = draggedBody.right - initialBody.right;
|
||||||
|
final trailing = draggedBody.left - initialBody.left;
|
||||||
|
expect(leading - trailing, greaterThan(7));
|
||||||
|
await gesture.cancel();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
testWidgets('inactive icon press leaves the cursor at its resting size', (
|
testWidgets('inactive icon press leaves the cursor at its resting size', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -25,7 +183,7 @@ void main() {
|
|||||||
await gesture.cancel();
|
await gesture.cancel();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
});
|
});
|
||||||
testWidgets('tap cursor travels monotonically with no size jitter', (
|
testWidgets('tap has bounded elastic stretch and settles on the new icon', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(_dock());
|
await tester.pumpWidget(_dock());
|
||||||
@@ -35,17 +193,165 @@ void main() {
|
|||||||
final target = tester.getCenter(find.byIcon(Icons.assignment_rounded)).dx;
|
final target = tester.getCenter(find.byIcon(Icons.assignment_rounded)).dx;
|
||||||
await tester.tap(find.byIcon(Icons.assignment_rounded));
|
await tester.tap(find.byIcon(Icons.assignment_rounded));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
var previous = resting.center.dx;
|
var widest = resting.width;
|
||||||
for (var frame = 0; frame < 50; frame++) {
|
var narrowest = resting.width;
|
||||||
|
var tallest = resting.height;
|
||||||
|
var furthest = resting.center.dx;
|
||||||
|
for (var frame = 0; frame < 70; frame++) {
|
||||||
await tester.pump(const Duration(milliseconds: 16));
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
final rect = tester.getRect(lens);
|
final rect = tester.getRect(lens);
|
||||||
expect(rect.width, closeTo(resting.width, 0.05));
|
widest = widest > rect.width ? widest : rect.width;
|
||||||
expect(rect.height, closeTo(resting.height, 0.05));
|
narrowest = narrowest < rect.width ? narrowest : rect.width;
|
||||||
expect(rect.center.dx, greaterThanOrEqualTo(previous - 0.05));
|
tallest = tallest > rect.height ? tallest : rect.height;
|
||||||
expect(rect.center.dx, lessThanOrEqualTo(target + 0.05));
|
furthest = furthest > rect.center.dx ? furthest : rect.center.dx;
|
||||||
previous = rect.center.dx;
|
expect(rect.width, lessThan(resting.width * 1.7));
|
||||||
|
expect(rect.center.dx, lessThan(target + 22));
|
||||||
}
|
}
|
||||||
expect(previous, closeTo(target, 0.05));
|
expect(widest, greaterThan(resting.width * 1.40));
|
||||||
|
expect(narrowest, lessThan(resting.width * 0.97));
|
||||||
|
expect(tallest, greaterThan(resting.height * 1.25));
|
||||||
|
expect(furthest, greaterThan(target + 0.1));
|
||||||
|
expect(tester.getCenter(lens).dx, closeTo(target, 0.1));
|
||||||
|
expect(tester.getSize(lens).width, closeTo(resting.width, 0.1));
|
||||||
|
});
|
||||||
|
testWidgets('drag deforms the dock body while labels remain readable', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(_dock());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final body = find.byType(GlassRefraction).first;
|
||||||
|
final resting = tester.getRect(body);
|
||||||
|
final labelSize = tester.getSize(find.text('考试'));
|
||||||
|
final gesture = await tester.startGesture(
|
||||||
|
tester.getCenter(find.byKey(const Key('dock-cursor'))),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 180));
|
||||||
|
await gesture.moveBy(const Offset(75, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 150));
|
||||||
|
expect(tester.getSize(body).width, greaterThan(resting.width + 5));
|
||||||
|
expect(tester.getSize(body).height, greaterThan(resting.height + 2));
|
||||||
|
expect(tester.getSize(find.text('考试')), labelSize);
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.getSize(body), resting.size);
|
||||||
|
});
|
||||||
|
testWidgets(
|
||||||
|
'lens keeps softly stretching and squeezing during an even drag',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(_dock());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final lens = find.byType(GlassRefraction).last;
|
||||||
|
final gesture = await tester.startGesture(
|
||||||
|
tester.getCenter(find.byKey(const Key('dock-cursor'))),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 110));
|
||||||
|
await tester.pump(const Duration(milliseconds: 700));
|
||||||
|
await gesture.moveBy(const Offset(24, 0));
|
||||||
|
await tester.pump();
|
||||||
|
final widths = <double>[], heights = <double>[], centres = <double>[];
|
||||||
|
for (var i = 0; i < 22; i++) {
|
||||||
|
await gesture.moveBy(const Offset(5, 0));
|
||||||
|
await tester.pump(const Duration(milliseconds: 20));
|
||||||
|
if (i > 3) {
|
||||||
|
final size = tester.getSize(lens);
|
||||||
|
widths.add(size.width);
|
||||||
|
heights.add(size.height);
|
||||||
|
centres.add(tester.getCenter(lens).dx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(widths.reduce(math.max) - widths.reduce(math.min), greaterThan(6));
|
||||||
|
expect(
|
||||||
|
heights.reduce(math.max) - heights.reduce(math.min),
|
||||||
|
greaterThan(2),
|
||||||
|
);
|
||||||
|
expect(centres.last - centres.first, greaterThan(50));
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.getSize(lens).width, closeTo(86, 0.1));
|
||||||
|
expect(tester.getSize(lens).height, closeTo(52, 0.1));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
testWidgets(
|
||||||
|
'rapid reverse taps keep position continuous and finish on last tab',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(_dock());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final lens = find.byType(GlassRefraction).last;
|
||||||
|
await tester.tap(find.byIcon(Icons.person_rounded));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 90));
|
||||||
|
final before = tester.getCenter(lens);
|
||||||
|
final beforeSize = tester.getSize(lens);
|
||||||
|
await tester.tap(find.byIcon(Icons.calendar_month_rounded));
|
||||||
|
await tester.pump();
|
||||||
|
expect((tester.getCenter(lens) - before).distance, lessThan(1));
|
||||||
|
expect(tester.getSize(lens).width, closeTo(beforeSize.width, 0.1));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
tester.getCenter(lens).dx,
|
||||||
|
closeTo(
|
||||||
|
tester.getCenter(find.byIcon(Icons.calendar_month_rounded)).dx,
|
||||||
|
0.1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
testWidgets('reversing a tap at the edge preserves glass size and position', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(_dock());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final lens = find.byType(GlassRefraction).last;
|
||||||
|
final body = find.byType(GlassRefraction).first;
|
||||||
|
await tester.tap(find.byIcon(Icons.person_rounded));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 230));
|
||||||
|
final before = tester.getRect(lens);
|
||||||
|
final bodyBefore = tester.getRect(body);
|
||||||
|
await tester.tap(find.byIcon(Icons.calendar_month_rounded));
|
||||||
|
await tester.pump();
|
||||||
|
final after = tester.getRect(lens);
|
||||||
|
expect(after.width, closeTo(before.width, 0.1));
|
||||||
|
expect(after.height, closeTo(before.height, 0.1));
|
||||||
|
expect(after.center.dx, closeTo(before.center.dx, 0.1));
|
||||||
|
expect(tester.getSize(body).width, closeTo(bodyBefore.width, 0.1));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
tester.getCenter(lens).dx,
|
||||||
|
closeTo(
|
||||||
|
tester.getCenter(find.byIcon(Icons.calendar_month_rounded)).dx,
|
||||||
|
0.1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
testWidgets('dock body follows a moving drag and springs back on cancel', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(_dock());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final body = find.byType(GlassRefraction).first;
|
||||||
|
final resting = tester.getRect(body);
|
||||||
|
final gesture = await tester.startGesture(
|
||||||
|
tester.getCenter(find.byKey(const Key('dock-cursor'))),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 110));
|
||||||
|
await tester.pump(const Duration(milliseconds: 350));
|
||||||
|
await gesture.moveBy(const Offset(24, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 350));
|
||||||
|
final held = tester.getRect(body);
|
||||||
|
await gesture.moveBy(const Offset(60, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
expect(tester.getSize(body).width, greaterThan(held.width + 0.5));
|
||||||
|
await gesture.cancel();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final settled = tester.getRect(body);
|
||||||
|
expect(settled.left, closeTo(resting.left, 0.05));
|
||||||
|
expect(settled.top, closeTo(resting.top, 0.05));
|
||||||
|
expect(settled.width, closeTo(resting.width, 0.05));
|
||||||
|
expect(settled.height, closeTo(resting.height, 0.05));
|
||||||
});
|
});
|
||||||
testWidgets('base and selected cursor both refract the live backdrop', (
|
testWidgets('base and selected cursor both refract the live backdrop', (
|
||||||
tester,
|
tester,
|
||||||
@@ -60,6 +366,59 @@ void main() {
|
|||||||
greaterThan(0),
|
greaterThan(0),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
testWidgets(
|
||||||
|
'drag moves the whole dock in its direction and returns on release',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(_dock());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byIcon(Icons.assignment_rounded));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final icon = find.byIcon(Icons.calendar_month_rounded);
|
||||||
|
final glass = find.byType(GlassRefraction).first;
|
||||||
|
final x = tester.getCenter(icon).dx;
|
||||||
|
final glassX = tester.getCenter(glass).dx;
|
||||||
|
final gesture = await tester.startGesture(
|
||||||
|
tester.getCenter(find.byKey(const Key('dock-cursor'))),
|
||||||
|
);
|
||||||
|
await gesture.moveBy(const Offset(24, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.moveBy(const Offset(45, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 140));
|
||||||
|
expect(tester.getCenter(icon).dx, greaterThan(x + 4));
|
||||||
|
expect(tester.getCenter(glass).dx, greaterThan(glassX + 4));
|
||||||
|
await gesture.moveBy(const Offset(-70, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 220));
|
||||||
|
expect(tester.getCenter(icon).dx, lessThan(x - 4));
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.getCenter(icon).dx, closeTo(x, 0.05));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
testWidgets('icon bounces once when the glass arrives and then rests', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(_dock());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final icon = find.byIcon(Icons.assignment_rounded);
|
||||||
|
final rest = tester.getRect(icon);
|
||||||
|
await tester.tap(icon);
|
||||||
|
await tester.pump();
|
||||||
|
var highest = rest.top;
|
||||||
|
var widest = rest.width;
|
||||||
|
for (var i = 0; i < 65; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
final r = tester.getRect(icon);
|
||||||
|
highest = math.min(highest, r.top);
|
||||||
|
widest = math.max(widest, r.width);
|
||||||
|
}
|
||||||
|
expect(highest, lessThan(rest.top - 1));
|
||||||
|
expect(widest, greaterThan(rest.width * 1.1));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.getRect(icon).top, closeTo(rest.top, 0.05));
|
||||||
|
expect(tester.getSize(icon).width, closeTo(rest.width, 0.05));
|
||||||
|
});
|
||||||
testWidgets('reduced motion snaps the cursor without oscillation', (
|
testWidgets('reduced motion snaps the cursor without oscillation', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -67,6 +426,7 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
final lens = find.byType(GlassRefraction).last;
|
final lens = find.byType(GlassRefraction).last;
|
||||||
final resting = tester.getSize(lens);
|
final resting = tester.getSize(lens);
|
||||||
|
final iconBefore = tester.getRect(find.byIcon(Icons.person_rounded));
|
||||||
await tester.tap(find.byIcon(Icons.person_rounded));
|
await tester.tap(find.byIcon(Icons.person_rounded));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
final target = tester.getCenter(find.byIcon(Icons.person_rounded)).dx;
|
final target = tester.getCenter(find.byIcon(Icons.person_rounded)).dx;
|
||||||
@@ -74,7 +434,94 @@ void main() {
|
|||||||
await tester.pump(const Duration(milliseconds: 200));
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
expect(tester.getCenter(lens).dx, closeTo(target, 0.05));
|
expect(tester.getCenter(lens).dx, closeTo(target, 0.05));
|
||||||
expect(tester.getSize(lens), resting);
|
expect(tester.getSize(lens), resting);
|
||||||
|
expect(tester.getRect(find.byIcon(Icons.person_rounded)), iconBefore);
|
||||||
});
|
});
|
||||||
|
testWidgets('hovering a dragged lens animates its icon before releasing', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(_dock());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final icon = find.byIcon(Icons.assignment_rounded);
|
||||||
|
final y = tester.getCenter(icon).dy;
|
||||||
|
final gesture = await tester.startGesture(
|
||||||
|
tester.getCenter(find.byKey(const Key('dock-cursor'))),
|
||||||
|
);
|
||||||
|
await gesture.moveBy(const Offset(24, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.moveBy(const Offset(86, 0));
|
||||||
|
await tester.pump();
|
||||||
|
for (var frame = 0; frame < 12; frame++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
}
|
||||||
|
expect(tester.getCenter(icon).dy, lessThan(y - 1));
|
||||||
|
await tester.pump(const Duration(milliseconds: 600));
|
||||||
|
expect(tester.getCenter(icon).dy, closeTo(y, 0.1));
|
||||||
|
await gesture.cancel();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
testWidgets('releasing over an animating icon preserves its bounce', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(_dock());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final icon = find.byIcon(Icons.assignment_rounded);
|
||||||
|
final gesture = await tester.startGesture(
|
||||||
|
tester.getCenter(find.byKey(const Key('dock-cursor'))),
|
||||||
|
);
|
||||||
|
await gesture.moveBy(const Offset(24, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.moveBy(const Offset(86, 0));
|
||||||
|
await tester.pump();
|
||||||
|
for (var frame = 0; frame < 12; frame++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
}
|
||||||
|
final before = tester.getRect(icon);
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
final after = tester.getRect(icon);
|
||||||
|
expect(after.top, closeTo(before.top, 0.1));
|
||||||
|
expect(after.width, closeTo(before.width, 0.1));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
testWidgets(
|
||||||
|
'changing reduced motion during a tap does not leave frozen deformation',
|
||||||
|
(tester) async {
|
||||||
|
final reduced = ValueNotifier(false);
|
||||||
|
var selected = 0;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: StatefulBuilder(
|
||||||
|
builder: (context, setState) => ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: reduced,
|
||||||
|
builder: (context, value, _) => MediaQuery(
|
||||||
|
data: MediaQueryData(disableAnimations: value),
|
||||||
|
child: Center(
|
||||||
|
child: GlassDock(
|
||||||
|
selectedIndex: selected,
|
||||||
|
onSelected: (index) => setState(() => selected = index),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final lens = find.byType(GlassRefraction).last;
|
||||||
|
final size = tester.getSize(lens);
|
||||||
|
await tester.tap(find.byIcon(Icons.assignment_rounded));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 120));
|
||||||
|
reduced.value = true;
|
||||||
|
await tester.pump();
|
||||||
|
reduced.value = false;
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.getSize(lens).width, closeTo(size.width, 0.05));
|
||||||
|
expect(tester.getSize(lens).height, closeTo(size.height, 0.05));
|
||||||
|
reduced.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
testWidgets('held cursor enlarges before dragging and returns on release', (
|
testWidgets('held cursor enlarges before dragging and returns on release', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:hepan_everyday/widgets/glass_dock.dart';
|
import 'package:hepan_everyday/widgets/glass_dock.dart';
|
||||||
|
import 'package:hepan_everyday/widgets/tab_deck.dart';
|
||||||
import 'package:hepan_everyday/app/hepan_app.dart';
|
import 'package:hepan_everyday/app/hepan_app.dart';
|
||||||
import 'package:hepan_everyday/features/timetable/timetable_page.dart';
|
import 'package:hepan_everyday/features/timetable/timetable_page.dart';
|
||||||
import 'package:timezone/data/latest.dart' as tzdata;
|
import 'package:timezone/data/latest.dart' as tzdata;
|
||||||
@@ -9,6 +10,83 @@ import 'navigation_test.dart' show uiController;
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
setUpAll(tzdata.initializeTimeZones);
|
setUpAll(tzdata.initializeTimeZones);
|
||||||
|
testWidgets(
|
||||||
|
'tab content finishes within one frame of 240ms and retains page state',
|
||||||
|
(tester) async {
|
||||||
|
Widget deck(int index) => MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: TabDeck(
|
||||||
|
index: index,
|
||||||
|
children: const [TextField(), Text('新页面')],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(deck(0));
|
||||||
|
await tester.enterText(find.byType(TextField), '保留内容');
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
await tester.pumpWidget(deck(1));
|
||||||
|
await tester.pump(); // Start the transition clock before advancing it.
|
||||||
|
await tester.pump(const Duration(milliseconds: 240));
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
expect(find.byType(TextField), findsNothing);
|
||||||
|
expect(find.text('新页面'), findsOneWidget);
|
||||||
|
await tester.pumpWidget(deck(0));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('保留内容'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
testWidgets('only the selected tab is blue, including during glass travel', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var selected = 0;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: StatefulBuilder(
|
||||||
|
builder: (context, setState) => Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: GlassDock(
|
||||||
|
selectedIndex: selected,
|
||||||
|
onSelected: (index) => setState(() => selected = index),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Color colour(IconData icon) =>
|
||||||
|
tester.widget<Icon>(find.byIcon(icon)).color!;
|
||||||
|
void expectGrey(IconData icon) {
|
||||||
|
final value = colour(icon);
|
||||||
|
expect((value.r - value.b).abs(), lessThan(0.12));
|
||||||
|
expect(value.a, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(colour(Icons.calendar_month_rounded), const Color(0xFF007AFF));
|
||||||
|
expectGrey(Icons.assignment_rounded);
|
||||||
|
expectGrey(Icons.person_rounded);
|
||||||
|
await tester.tap(find.byIcon(Icons.person_rounded));
|
||||||
|
await tester.pump();
|
||||||
|
// Colour follows the selected page, not whichever icon the lens crosses.
|
||||||
|
expect(colour(Icons.person_rounded), const Color(0xFF007AFF));
|
||||||
|
expectGrey(Icons.calendar_month_rounded);
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
expectGrey(Icons.assignment_rounded);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final gesture = await tester.startGesture(
|
||||||
|
tester.getCenter(find.byKey(const Key('dock-cursor'))),
|
||||||
|
);
|
||||||
|
await gesture.moveBy(const Offset(-20, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.moveBy(const Offset(-90, 0));
|
||||||
|
await tester.pump(const Duration(milliseconds: 150));
|
||||||
|
expect(colour(Icons.person_rounded), const Color(0xFF007AFF));
|
||||||
|
expectGrey(Icons.assignment_rounded);
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(selected, 1);
|
||||||
|
expect(colour(Icons.assignment_rounded), const Color(0xFF007AFF));
|
||||||
|
expectGrey(Icons.person_rounded);
|
||||||
|
});
|
||||||
testWidgets('dock drags switch tabs while body swipes only change week', (
|
testWidgets('dock drags switch tabs while body swipes only change week', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -9,6 +9,61 @@ import 'package:hepan_everyday/widgets/glass_dock.dart';
|
|||||||
import 'package:hepan_everyday/widgets/glass_refraction.dart';
|
import 'package:hepan_everyday/widgets/glass_refraction.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
testWidgets(
|
||||||
|
'zero-blur glass bends an already-painted symbol without erasing it',
|
||||||
|
(tester) async {
|
||||||
|
final plain = await _render(
|
||||||
|
tester,
|
||||||
|
const GlassRefraction(
|
||||||
|
strength: 0,
|
||||||
|
blurSigma: 0,
|
||||||
|
child: SizedBox.expand(),
|
||||||
|
),
|
||||||
|
opticalSymbol: true,
|
||||||
|
);
|
||||||
|
final bent = await _render(
|
||||||
|
tester,
|
||||||
|
const GlassRefraction(
|
||||||
|
strength: 0.52,
|
||||||
|
blurSigma: 0,
|
||||||
|
child: SizedBox.expand(),
|
||||||
|
),
|
||||||
|
opticalSymbol: true,
|
||||||
|
);
|
||||||
|
var changed = 0, plainBlue = 0, bentBlue = 0;
|
||||||
|
for (var y = 16; y < 50; y++) {
|
||||||
|
for (var x = 5; x < 52; x++) {
|
||||||
|
final before = _channel(plain, x, y, 0);
|
||||||
|
final after = _channel(bent, x, y, 0);
|
||||||
|
if ((before - after).abs() > 20) changed++;
|
||||||
|
if (_channel(plain, x, y, 2) - before > 100) plainBlue++;
|
||||||
|
if (_channel(bent, x, y, 2) - after > 100) bentBlue++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(changed, greaterThan(20));
|
||||||
|
expect(bentBlue, greaterThan(plainBlue * 0.7));
|
||||||
|
},
|
||||||
|
skip: !ui.ImageFilter.isShaderFilterSupported,
|
||||||
|
);
|
||||||
|
testWidgets(
|
||||||
|
'Dock has a milky white body while transmitting a dark backdrop',
|
||||||
|
(tester) async {
|
||||||
|
final pixels = await _render(
|
||||||
|
tester,
|
||||||
|
GlassDock(selectedIndex: 0, onSelected: (_) {}),
|
||||||
|
backdropColor: const Color(0xFF162536),
|
||||||
|
preview: true,
|
||||||
|
previewName: 'liquid-glass-milky-dark.png',
|
||||||
|
);
|
||||||
|
// Away from icons and the bevel: white glass lifts the dark background,
|
||||||
|
// while its original cool colour remains visible through the material.
|
||||||
|
final red = _channel(pixels, 160, 43, 0);
|
||||||
|
final green = _channel(pixels, 160, 43, 1);
|
||||||
|
expect(red, greaterThan(75));
|
||||||
|
expect(red, lessThan(112));
|
||||||
|
expect(green - red, closeTo(13, 6));
|
||||||
|
},
|
||||||
|
);
|
||||||
testWidgets('resting cursor is neutral grey and clears while held', (
|
testWidgets('resting cursor is neutral grey and clears while held', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -21,6 +76,8 @@ void main() {
|
|||||||
// Sample beside the visible label, away from icon glow and glass rims.
|
// Sample beside the visible label, away from icon glow and glass rims.
|
||||||
final selected = _channel(resting, 16, 43, 0);
|
final selected = _channel(resting, 16, 43, 0);
|
||||||
final base = _channel(resting, 160, 43, 0);
|
final base = _channel(resting, 160, 43, 0);
|
||||||
|
// On a white page the white glass must not turn into a grey veil.
|
||||||
|
expect(base, greaterThanOrEqualTo(240));
|
||||||
expect(base - selected, greaterThan(20));
|
expect(base - selected, greaterThan(20));
|
||||||
expect((_channel(resting, 16, 43, 2) - selected).abs(), lessThan(12));
|
expect((_channel(resting, 16, 43, 2) - selected).abs(), lessThan(12));
|
||||||
final held = await _render(
|
final held = await _render(
|
||||||
@@ -64,11 +121,10 @@ void main() {
|
|||||||
final red = _channel(pixels, 16, 40, 0);
|
final red = _channel(pixels, 16, 40, 0);
|
||||||
final green = _channel(pixels, 16, 40, 1);
|
final green = _channel(pixels, 16, 40, 1);
|
||||||
final blue = _channel(pixels, 16, 40, 2);
|
final blue = _channel(pixels, 16, 40, 2);
|
||||||
expect(red, lessThan(85));
|
expect(red, lessThan(125));
|
||||||
expect(green - red, greaterThan(95));
|
expect(green - red, greaterThanOrEqualTo(65));
|
||||||
// The strongest resting tint (34%) and base veil (5%) retain about 63%
|
// The thicker milky base still transmits a clear cyan colour difference.
|
||||||
// of the source's 210-level blue/red contrast, including the shorter rim.
|
expect(blue - red, greaterThan(90));
|
||||||
expect(blue - red, greaterThan(130));
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -112,8 +168,8 @@ void main() {
|
|||||||
reds.reduce((a, b) => a > b ? a : b) -
|
reds.reduce((a, b) => a > b ? a : b) -
|
||||||
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
|
// The original stripes have 180 levels of contrast. Both the base and
|
||||||
// selected lens retain over half, keeping these larger shapes readable.
|
// selected lens retain at least 40%, keeping larger shapes readable.
|
||||||
expect(contrast, greaterThan(90), reason: 'background at x=$start');
|
expect(contrast, greaterThan(72), reason: 'background at x=$start');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -166,9 +222,11 @@ Future<ByteData> _render(
|
|||||||
double stripeWidth = 2,
|
double stripeWidth = 2,
|
||||||
bool gradient = false,
|
bool gradient = false,
|
||||||
bool preview = false,
|
bool preview = false,
|
||||||
|
String? previewName,
|
||||||
Offset offset = Offset.zero,
|
Offset offset = Offset.zero,
|
||||||
Color backdropColor = const Color(0xFF14B4E6),
|
Color backdropColor = const Color(0xFF14B4E6),
|
||||||
bool held = false,
|
bool held = false,
|
||||||
|
bool opticalSymbol = false,
|
||||||
}) async {
|
}) async {
|
||||||
tester.view.physicalSize = const Size(800, 600);
|
tester.view.physicalSize = const Size(800, 600);
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
@@ -196,6 +254,7 @@ Future<ByteData> _render(
|
|||||||
gradient,
|
gradient,
|
||||||
stripeWidth,
|
stripeWidth,
|
||||||
backdropColor,
|
backdropColor,
|
||||||
|
opticalSymbol,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -246,11 +305,13 @@ Future<ByteData> _render(
|
|||||||
final png = (await previewImage.toByteData(
|
final png = (await previewImage.toByteData(
|
||||||
format: ui.ImageByteFormat.png,
|
format: ui.ImageByteFormat.png,
|
||||||
))!;
|
))!;
|
||||||
final name = backdropColor == Colors.white
|
final name =
|
||||||
? held
|
previewName ??
|
||||||
? 'liquid-glass-held.png'
|
(backdropColor == Colors.white
|
||||||
: 'liquid-glass-rest.png'
|
? held
|
||||||
: 'liquid-glass-detail.png';
|
? 'liquid-glass-held.png'
|
||||||
|
: 'liquid-glass-rest.png'
|
||||||
|
: 'liquid-glass-detail.png');
|
||||||
final output = File('../dist/screenshots/$name');
|
final output = File('../dist/screenshots/$name');
|
||||||
await output.parent.create(recursive: true);
|
await output.parent.create(recursive: true);
|
||||||
await output.writeAsBytes(png.buffer.asUint8List());
|
await output.writeAsBytes(png.buffer.asUint8List());
|
||||||
@@ -280,14 +341,40 @@ Future<ByteData> _render(
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _Backdrop extends CustomPainter {
|
class _Backdrop extends CustomPainter {
|
||||||
const _Backdrop(this.stripes, this.gradient, this.stripeWidth, this.colour);
|
const _Backdrop(
|
||||||
|
this.stripes,
|
||||||
|
this.gradient,
|
||||||
|
this.stripeWidth,
|
||||||
|
this.colour,
|
||||||
|
this.opticalSymbol,
|
||||||
|
);
|
||||||
final bool stripes;
|
final bool stripes;
|
||||||
final bool gradient;
|
final bool gradient;
|
||||||
final double stripeWidth;
|
final double stripeWidth;
|
||||||
final Color colour;
|
final Color colour;
|
||||||
|
final bool opticalSymbol;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
|
if (opticalSymbol) {
|
||||||
|
canvas.drawRect(Offset.zero & size, Paint()..color = Colors.white);
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(
|
||||||
|
const Rect.fromLTWH(10, 20, 28, 25),
|
||||||
|
const Radius.circular(3),
|
||||||
|
),
|
||||||
|
Paint()..color = const Color(0xFF007AFF),
|
||||||
|
);
|
||||||
|
for (var row = 0; row < 2; row++) {
|
||||||
|
for (var col = 0; col < 3; col++) {
|
||||||
|
canvas.drawRect(
|
||||||
|
Rect.fromLTWH(15 + col * 7, 26 + row * 8, 4, 4),
|
||||||
|
Paint()..color = Colors.white,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (gradient) {
|
if (gradient) {
|
||||||
for (var x = 0; x < size.width; x++) {
|
for (var x = 0; x < size.width; x++) {
|
||||||
canvas.drawRect(
|
canvas.drawRect(
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ class _Notifications extends FakeNotifications {
|
|||||||
'model': 'TestModel',
|
'model': 'TestModel',
|
||||||
'sdk': 35,
|
'sdk': 35,
|
||||||
'backgroundRestricted': true,
|
'backgroundRestricted': true,
|
||||||
|
'standbyBucket': 45,
|
||||||
|
'startupAlarmAudit': {
|
||||||
|
'capturedAtEpochMillis': 123,
|
||||||
|
'cachedCourseCount': 3,
|
||||||
|
'existingHandleCount': 1,
|
||||||
|
'missingHandleCount': 2,
|
||||||
|
'courseName': 'Private course name',
|
||||||
|
},
|
||||||
'account': 'private@example.com',
|
'account': 'private@example.com',
|
||||||
},
|
},
|
||||||
'courseNames': ['Private course name'],
|
'courseNames': ['Private course name'],
|
||||||
@@ -70,6 +78,42 @@ Future<void> _open(WidgetTester tester, _Notifications notifications) async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
testWidgets(
|
||||||
|
'Android 7 does not ask the user to create an unsupported category',
|
||||||
|
(tester) async {
|
||||||
|
final notifications = _Notifications()
|
||||||
|
..diagnosis = Completer<Map<String, Object?>>();
|
||||||
|
notifications.diagnosis!.complete({
|
||||||
|
'notifications': true,
|
||||||
|
'appNotificationsEnabled': true,
|
||||||
|
'channelExists': false,
|
||||||
|
'system': {'sdk': 24},
|
||||||
|
});
|
||||||
|
await _open(tester, notifications);
|
||||||
|
await tester.tap(find.text('检查提醒'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('类别尚未建立'), findsNothing);
|
||||||
|
expect(find.text('系统通知总开关:已允许'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
testWidgets(
|
||||||
|
'diagnosis separates the application switch from the reminder category',
|
||||||
|
(tester) async {
|
||||||
|
final notifications = _Notifications()
|
||||||
|
..diagnosis = Completer<Map<String, Object?>>();
|
||||||
|
notifications.diagnosis!.complete({
|
||||||
|
'notifications': false,
|
||||||
|
'appNotificationsEnabled': false,
|
||||||
|
'channelExists': true,
|
||||||
|
'courseChannelEnabled': true,
|
||||||
|
});
|
||||||
|
await _open(tester, notifications);
|
||||||
|
await tester.tap(find.text('检查提醒'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('系统通知总开关:未允许'), findsOneWidget);
|
||||||
|
expect(find.text('课前提醒类别:已允许'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
testWidgets('test controls distinguish immediate and background reminders', (
|
testWidgets('test controls distinguish immediate and background reminders', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -93,7 +137,7 @@ void main() {
|
|||||||
final previous = notifications.reconciliations;
|
final previous = notifications.reconciliations;
|
||||||
await tester.tap(find.text('检查提醒'));
|
await tester.tap(find.text('检查提醒'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(notifications.reconciliationsAtDiagnosis, previous + 1);
|
expect(notifications.reconciliationsAtDiagnosis, previous);
|
||||||
for (final button in tester.widgetList<TextButton>(
|
for (final button in tester.widgetList<TextButton>(
|
||||||
find.byType(TextButton),
|
find.byType(TextButton),
|
||||||
)) {
|
)) {
|
||||||
@@ -138,5 +182,12 @@ void main() {
|
|||||||
expect(report['courseCount'], 0);
|
expect(report['courseCount'], 0);
|
||||||
expect(report['appVersion'], isNotEmpty);
|
expect(report['appVersion'], isNotEmpty);
|
||||||
expect((report['system'] as Map)['model'], 'TestModel');
|
expect((report['system'] as Map)['model'], 'TestModel');
|
||||||
|
expect(
|
||||||
|
((report['system'] as Map)['startupAlarmAudit']
|
||||||
|
as Map)['missingHandleCount'],
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
expect((report['beforeCheck'] as Map)['pendingCount'], 2);
|
||||||
|
expect(find.textContaining('系统待机限制'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:timezone/data/latest.dart' as tzdata;
|
||||||
|
import 'package:hepan_everyday/app/hepan_app.dart';
|
||||||
|
import 'package:hepan_everyday/features/timetable/week_grid.dart';
|
||||||
|
import 'package:hepan_everyday/features/exams/exams_page.dart';
|
||||||
|
import 'package:hepan_everyday/features/profile/profile_page.dart';
|
||||||
|
import 'package:hepan_everyday/widgets/glass_dock.dart';
|
||||||
|
import 'package:hepan_everyday/features/profile/settings_page.dart';
|
||||||
|
import 'package:hepan_everyday/features/feedback/feedback_page.dart';
|
||||||
|
|
||||||
|
import 'navigation_test.dart' show uiController;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(tzdata.initializeTimeZones);
|
||||||
|
for (final size in [const Size(800, 1280), const Size(1280, 800)]) {
|
||||||
|
testWidgets('tablet $size fits seven days and limits reading width', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.view.physicalSize = size;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
final controller = uiController();
|
||||||
|
await controller.initialize();
|
||||||
|
await controller.showDemo();
|
||||||
|
await tester.pumpWidget(HepanApp(controller: controller));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(tester.getRect(find.text('周一')).left, greaterThan(0));
|
||||||
|
expect(tester.getRect(find.text('周日')).right, lessThan(size.width));
|
||||||
|
final monday = tester.widget<Text>(find.text('周一'));
|
||||||
|
expect(monday.style!.fontSize, greaterThanOrEqualTo(12));
|
||||||
|
expect(tester.getSize(find.byType(GlassDock)).width, 300);
|
||||||
|
expect(tester.getSize(find.byType(GlassDock)).height, 58);
|
||||||
|
await tester.drag(find.text('周三'), const Offset(-160, 0));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('第 2 周'), findsOneWidget);
|
||||||
|
await tester.tap(find.byIcon(Icons.assignment_rounded));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byType(ExamsPage)).width,
|
||||||
|
lessThanOrEqualTo(840),
|
||||||
|
);
|
||||||
|
await tester.tap(find.byIcon(Icons.person_rounded));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byType(ProfilePage)).width,
|
||||||
|
lessThanOrEqualTo(840),
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
await tester.tap(find.byIcon(Icons.calendar_month_rounded));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('第 2 周'), findsOneWidget);
|
||||||
|
tester.view.physicalSize = const Size(480, 800);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.getSize(find.byType(GlassDock)).width, 264);
|
||||||
|
expect(find.text('第 2 周'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.getRect(find.byType(WeekGrid)).right,
|
||||||
|
lessThanOrEqualTo(480),
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
controller.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
testWidgets(
|
||||||
|
'wide settings and feedback remain centered and usable with large text',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.view.physicalSize = const Size(1280, 800);
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
final controller = uiController();
|
||||||
|
await controller.initialize();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: MediaQuery(
|
||||||
|
data: const MediaQueryData(
|
||||||
|
size: Size(1280, 800),
|
||||||
|
textScaler: TextScaler.linear(1.5),
|
||||||
|
),
|
||||||
|
child: SettingsPage(controller: controller),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.getSize(find.byType(ListView)).width, 760);
|
||||||
|
expect(tester.getRect(find.byType(ListView)).center.dx, 640);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: MediaQuery(
|
||||||
|
data: const MediaQueryData(
|
||||||
|
size: Size(1280, 800),
|
||||||
|
textScaler: TextScaler.linear(1.5),
|
||||||
|
),
|
||||||
|
child: const FeedbackPage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.getSize(find.byType(ListView)).width, 760);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
await tester.enterText(find.byKey(const Key('feedback-message')), '平板反馈');
|
||||||
|
expect(find.text('平板反馈'), findsOneWidget);
|
||||||
|
controller.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,7 +45,8 @@ void main() {
|
|||||||
final a = tester.getRect(find.bySemanticsLabel('A,T,R'));
|
final a = tester.getRect(find.bySemanticsLabel('A,T,R'));
|
||||||
final b = tester.getRect(find.bySemanticsLabel('B,T,R'));
|
final b = tester.getRect(find.bySemanticsLabel('B,T,R'));
|
||||||
final cRect = tester.getRect(find.bySemanticsLabel('C,T,R'));
|
final cRect = tester.getRect(find.bySemanticsLabel('C,T,R'));
|
||||||
expect(a.overlaps(b), isFalse);
|
// Fractional Expanded heights can differ by floating-point epsilon.
|
||||||
expect(b.overlaps(cRect), isFalse);
|
expect(b.top, greaterThanOrEqualTo(a.bottom - 0.001));
|
||||||
|
expect(cRect.top, greaterThanOrEqualTo(b.bottom - 0.001));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ void main() {
|
|||||||
session.dispose();
|
session.dispose();
|
||||||
controller.dispose();
|
controller.dispose();
|
||||||
});
|
});
|
||||||
Future<void> open(WidgetTester tester) async {
|
Future<void> open(WidgetTester tester, {String? timetableHtml}) async {
|
||||||
controller = AppController(
|
controller = AppController(
|
||||||
store,
|
store,
|
||||||
FakeNotifications(),
|
FakeNotifications(),
|
||||||
@@ -43,8 +43,9 @@ void main() {
|
|||||||
platform.web.javascript = (s) async => underEvidence(
|
platform.web.javascript = (s) async => underEvidence(
|
||||||
platform.web.url!,
|
platform.web.url!,
|
||||||
html: s == undergraduateCaptureScript
|
html: s == undergraduateCaptureScript
|
||||||
? File('test/fixtures/school/undergraduate-timetable.html')
|
? timetableHtml ??
|
||||||
.readAsStringSync()
|
File('test/fixtures/school/undergraduate-timetable.html')
|
||||||
|
.readAsStringSync()
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
platform.delegate.finished(platform.web.url!);
|
platform.delegate.finished(platform.web.url!);
|
||||||
@@ -85,6 +86,21 @@ void main() {
|
|||||||
expect(find.text('打开'), findsOneWidget);
|
expect(find.text('打开'), findsOneWidget);
|
||||||
SchoolPageDiagnostic.instance.clear();
|
SchoolPageDiagnostic.instance.clear();
|
||||||
});
|
});
|
||||||
|
testWidgets('missing room imports with a visible pending-room notice', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await open(
|
||||||
|
tester,
|
||||||
|
timetableHtml: File('test/fixtures/school/undergraduate-timetable.html')
|
||||||
|
.readAsStringSync()
|
||||||
|
.replaceFirst('"教室1"', '""'),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('导入当前课表'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(controller.snapshot.courses.any((c) => c.room == '教室待定'), isTrue);
|
||||||
|
expect(find.textContaining('部分课程教室待定'), findsOneWidget);
|
||||||
|
SchoolPageDiagnostic.instance.clear();
|
||||||
|
});
|
||||||
testWidgets('cross school replacement can be cancelled without saving', (
|
testWidgets('cross school replacement can be cancelled without saving', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user