From 8403be05da5728560cdc7c0ef19c47540a76d51d Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 13 Oct 2026 11:18:50 +0800 Subject: [PATCH] feat: deliver Riverside Schedule seven-day grid and school HTML import --- README.md | 51 +- docs/riverside-revision-validation.md | 23 + docs/school-integration-status.md | 18 +- .../android/app/src/main/AndroidManifest.xml | 2 +- mobile/lib/app/hepan_app.dart | 2 +- mobile/lib/data/json_schedule_import.dart | 2 + mobile/lib/data/schedule_file_import.dart | 17 + .../lib/features/profile/import_preview.dart | 1 + mobile/lib/features/profile/profile_page.dart | 35 +- .../lib/features/profile/settings_page.dart | 2 +- .../lib/features/school/school_auth_page.dart | 4 +- .../school/school_navigation_policy.dart | 5 +- .../features/school/school_page_adapter.dart | 194 +++++- .../lib/features/school/school_session.dart | 21 +- .../features/timetable/timetable_page.dart | 58 +- mobile/lib/features/timetable/week_grid.dart | 593 ++++++++++-------- .../services/android_calendar_service.dart | 2 +- mobile/lib/widgets/glass_dock.dart | 191 ++++-- mobile/pubspec.lock | 16 + mobile/pubspec.yaml | 5 +- mobile/test/fixtures/school/README.md | 8 +- .../test/school/import_boundaries_test.dart | 67 ++ .../school/recognized_timetable_test.dart | 84 +++ .../test/widgets/complete_metadata_test.dart | 86 +++ .../test/widgets/conflict_metadata_test.dart | 124 ++++ mobile/test/widgets/glass_dock_test.dart | 70 +++ mobile/test/widgets/navigation_test.dart | 3 +- mobile/test/widgets/timetable_test.dart | 4 +- scripts/build-android.ps1 | 4 +- 29 files changed, 1246 insertions(+), 446 deletions(-) create mode 100644 docs/riverside-revision-validation.md create mode 100644 mobile/lib/data/schedule_file_import.dart create mode 100644 mobile/test/school/import_boundaries_test.dart create mode 100644 mobile/test/school/recognized_timetable_test.dart create mode 100644 mobile/test/widgets/complete_metadata_test.dart create mode 100644 mobile/test/widgets/conflict_metadata_test.dart create mode 100644 mobile/test/widgets/glass_dock_test.dart diff --git a/README.md b/README.md index ff10805..c9d5025 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,36 @@ -# 河畔Everyday +# Riverside Schedule -电子科技大学研究生课表 APP,Flutter 实现,Android 首版 0.1.0+1,Android 7.0 及以上。iOS 尚未配置。 +电子科技大学研究生课表 APP,Flutter 实现,Android 0.2.0+2,Android 7.0 及以上。 -已实现:周一至周日、11 节学校作息、跨节课程条形卡片、课程详情与冲突提示、两个无文字图标的玻璃悬浮 Dock、单色/多彩设置、学期开始日/周数设置、JSON 预览导入、本地保存/备份恢复、课前通知及手机日历同步。 +课表周一至周日固定七列同屏,无横向滚动;全表使用统一节次行高,同节数课程条等长。课程名最多两行,可省略,老师和教室完整换行。点击课程查看全称、老师、教室、节次、日期和上课周次。时间冲突用整列宽度的冲突列表展示,列表各项标注节次,避免窄列裁字。 -学校认证提供自绘登录入口与官方 WebView。**本人登录、验证码及研究生课表自动识别仍待真实页面联调**,目前不会假装成功导入。详见 [学校接入状态](docs/school-integration-status.md)。 +底部只有日历和我的两个图标,玻璃风格悬浮 Dock 支持点击图标、拖动选中游标切换。页面本身不支持左右滑动切换。配色、课前提醒、手机日历和学期设置延续首版。 -## 体验 +## 安装和导入 -安装 `dist/hepan-everyday-debug.apk`。这是开发测试包,不是商店发布包。首次打开点“体验示例课表”即可预览;示例课程不会写入手机日历或触发通知。 +安装 `dist/Riverside-Schedule-debug.apk`。这是开发测试包;包名和原版签名保持一致,可覆盖安装并保留本地数据。 -自己的课程:在“我的 → 课表设置”填写学校实际开始日和周数,再通过“导入课表文件”导入 JSON 并确认预览。默认提前 15 分钟提醒,可选 5、10、15、30、60 分钟或关闭。首次启用需要允许通知;没有精确闹钟权限时会显示可能延迟。 +在「我的 → 从文件导入」选择学校课表查询页保存的 `.html` / `.htm`,或版本1的课表 JSON。先检查预览,再确认导入。文件最多2MB,UTF-8编码。学校 HTML 中分散周次会合并为同一星期、节次、老师和教室的一条安排。 -日历同步需授权并选择可写日历。关闭可保留或删除本 APP 创建的日程。删除时检查课程实例与本 APP 专属标记,不操作其他日程;清理失败保留映射并可重试。日历事件不创建第二份系统提醒。 +HTML 不含学期开始日,因此仍须在「我的 → 课表设置」填写实际开始日和学期周数。默认课前15分钟提醒,可选5/10/15/30/60分钟。启用通知和手机日历时按页面提示授权。 -## JSON 文件 +学校入口连接 [研究生课表查询页面](https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1),提供自绘认证入口及学校官方 WebView。已根据提供的实际HTML实现解析,实时个人登录和验证码流程仍需在本人手机上验证。文件导入不会冒充学校登录。详见 [学校接入状态](docs/school-integration-status.md)。 -样例:[schedule.schema-v1.json](examples/schedule.schema-v1.json)。 +JSON样例:[schedule.schema-v1.json](examples/schedule.schema-v1.json)。示例模式独立,不覆盖本人课表,也不创建通知或日历事件。 -| 字段 | 含义 | -|---|---| -| schemaVersion | 固定为 1 | -| isDemo | 样例为 true;导入本人课程改成 false | -| explicitlyEmpty | 只有明确要清空且 courses=[] 时设 true,并在预览再次确认 | -| id | 每条课程安排唯一的非空标识 | -| name / teacher / room | 课程名、教师、教室,均非空 | -| weekday | 周一至周日:1–7 | -| startPeriod / endPeriod | 开始/结束节次:1–11,包含边界 | -| weeks | 实际上课周次数组:1–30;单双周展开成明确周次 | +## 开发 -同一课程不同星期或不同节次,应使用不同 id。文件上限 2 MB。导入文件不能冒充学校来源;示例模式不覆盖真实课表。 - -## 开发与构建 - -源码位于 `mobile/`。本次工具在 `C:/Users/david/Desktop/app/.tools`,不会进入 Git。Windows PowerShell: +源码在 `mobile/`;内部 Dart 包名保留 `hepan_everyday`,Android包名保留 `com.example.hepan_everyday` 以兼容升级。本机工具位于 `.tools/`,不会提交进Git。 ```powershell $env:HEPAN_TOOLCHAIN_ROOT='C:/Users/david/Desktop/app/.tools' . ./scripts/android-env.ps1 Set-Location mobile flutter pub get -flutter analyze flutter test -flutter run -d emulator-5554 +flutter analyze ``` -回到仓库根目录构建: +仓库根目录运行 `./scripts/build-android.ps1` 构建,成功后复制 APK 到 `dist/`。 -```powershell -./scripts/build-android.ps1 -``` - -构建成功才复制新 APK 到 `dist/`。工具版本与设备环境见 [Android 环境](docs/android-setup-status.md)。其他电脑可使用 `scripts/setup-android.ps1 -WithEmulator` 安装环境,并将构建脚本 ToolchainRoot 指向对应 `.tools` 目录。 - -设备集成检查见 [验证记录](docs/android-mvp-validation.md)。系统日历测试仅运行于专用模拟器的 `Hepan_Integration_Test` 测试日历,需提前允许测试 APP 读取和写入日历。`mobile/tool/reminder_probe.dart` 是短时系统提醒测试入口,交付 APK 始终使用 `lib/main.dart`。 -损坏文件无法从备份恢复时,可在提示区选择“重新建立课表”,确认后保留损坏文件副本并恢复保存功能。手机日历中的历史日程需按提示处理。 +验证记录:[0.2.0改版验证](docs/riverside-revision-validation.md)。Windows模拟器如遇图形驱动崩溃,可仅在测试启动时使用软件渲染并关闭Impeller;交付APK保留默认手机渲染配置。首版通知和日历原生验证保存在 [首版验证记录](docs/android-mvp-validation.md)。 diff --git a/docs/riverside-revision-validation.md b/docs/riverside-revision-validation.md new file mode 100644 index 0000000..5d43e8f --- /dev/null +++ b/docs/riverside-revision-validation.md @@ -0,0 +1,23 @@ +# Riverside Schedule 0.2.0+2 改版验证 + +日期:2026-10-13。Android 包名保持 com.example.hepan_everyday,显示名称 Riverside Schedule。 + +## 自动化验证 + +- 72项单元及组件测试通过;静态检查无问题。 +- 360px屏幕的七天同屏、100%和200%字体完整老师/教室、相同节数卡片等长、课程详情完整字段。 +- 320px和200%字体下5条冲突安排使用全宽明确节次列表,文字不裁切、列表不互相覆盖。 +- Dock图标点击、选中玻璃游标双向拖动、取消拖动恢复、页面横向滑动不换页。 +- 实际提供HTML作为本机验证输入:7门课程、11条安排;周次合并、晚间9–11节、多教师。 +- 不完整表格、错误星期/节次/周次、空页面拒绝成功导入;文件来源不冒充实时学校登录;读取取消和来源边界。 +- 原有存储恢复、设置队列、通知及日历服务回归测试保留。 + +## 安卓人工验证 + +API36模拟器:选择学校HTML → 11条安排预览 → 确认导入 → 设置测试学期开始日 → 七列课表。点击省略课程名的详情显示全称、全部老师、教室、节次及周次。Dock双向拖动切页,页面横向滑动保持当前页。 + +测试用日期2026-10-12仅用于模拟器展示,不是推断的真实学期开始日。安装包中不预置本人课程或这个测试日期。 + +Windows模拟器默认图形路径出现qemu进程访问冲突;验证时仅通过启动参数关闭Impeller并使用软件渲染。交付APK未改变手机默认渲染配置,仍需实际安卓手机外观和实时学校登录验收。 + +截图位于交付目录dist/screenshots。最终APK、SHA256与源码包见dist/README.md。 diff --git a/docs/school-integration-status.md b/docs/school-integration-status.md index 0ef05dc..c917a06 100644 --- a/docs/school-integration-status.md +++ b/docs/school-integration-status.md @@ -1,11 +1,19 @@ # 学校接入状态 -当前首版提供自绘账号密码入口,以及学校官方页面进行验证码、二次验证和登录。认证使用系统 WebView 与学校原有提交及加密逻辑;密码不保存到本地文件,不发送到第三方服务器。退出会清除该 WebView 的 Cookie、缓存和本地存储,可选择保留或清除已导入课表。 +0.2.0已实现提供的研究生课表HTML结构解析,入口为 https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1,允许学校同路径下数字学期编号。保留HTTPS、域名、端口、登录表单检查;认证密码仍仅交给学校原有表单及加密逻辑,不保存到课表文件。 -已检查公开的 [统一身份认证页面](https://idas.uestc.edu.cn/authserver/login) 及脚本,核实表单结构和学校原有函数。登录前再次检查 HTTPS、域名、端口、路径和表单结构;页面变化时停止自动提交,仍可使用官方页面完成操作。 +## 已验证 -**尚未完成本人登录及真实课表自动识别。** [研究生课表查询页](https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1) 需要本人会话,当前没有取得页面样本。解析器遇到当前未知页面明确提示“学校课表识别待联调,当前课表保持不变”,不会生成虚假课程或清空原课表。 +- 提供的HTML:7门课程,11条每周安排;分散周次合并;多位老师和教室完整保留;晚上9–11节保留三节。 +- 课表表格节次、星期列、课程记录、周次及单元格位置逐项校验。未知、部分损坏或空HTML拒绝导入,保留旧课表。 +- `.html`/`.htm`与JSON文件都先预览再确认。离线HTML只记为文件来源,不修改学校认证状态。 +- 正在读取的课表在导航或退出登录后失效,迟到的结果不能恢复登录状态或导入预览。 +- 原始附件中的学号、脚本和其他个人信息未放入源码或安装包。自动化测试使用合成课表;原始附件仅作为本机可选验证输入。 -后续在本人手机登录后,需要用脱敏课表页面核实:课程字段、单/双周、周次范围、连续节次、校区、空课表和会话失效。页面中学生姓名、学号、Cookie、令牌及密码应移除后才作为开发样本。取得样本后增加解析器与回归测试,再开放真实自动导入。 +## 仍需本人验证 -首版可体验内置示例,或通过 JSON 导入本人课程。JSON 导入先预览再确认,失败保留原数据;示例模式独立,不写手机日历或安排系统通知。 +实时统一身份认证、验证码、二次验证及学校登录重定向尚未用本人账户实测。解析器已连接WebView读取流程,但不能据离线HTML宣称实时登录已验证。 + +当前解析器支持已提供表格的明确周次范围及周次列表;未知单双周文字或新的页面结构拒绝导入。没有课程的页面不会被猜测为清空指令。 + +HTML没有提供学期开始日,导入不会推测或改写学期日期;需要在设置填写真实日期和周数。 diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index b0e55b8..e330ede 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -6,7 +6,7 @@ diff --git a/mobile/lib/app/hepan_app.dart b/mobile/lib/app/hepan_app.dart index c79ebc5..ba12602 100644 --- a/mobile/lib/app/hepan_app.dart +++ b/mobile/lib/app/hepan_app.dart @@ -26,7 +26,7 @@ class HepanApp extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp( debugShowCheckedModeBanner: false, - title: '河畔Everyday', + title: 'Riverside Schedule', theme: hepanTheme(), locale: const Locale('zh', 'CN'), supportedLocales: const [Locale('zh', 'CN')], diff --git a/mobile/lib/data/json_schedule_import.dart b/mobile/lib/data/json_schedule_import.dart index 1cbce10..bcfd193 100644 --- a/mobile/lib/data/json_schedule_import.dart +++ b/mobile/lib/data/json_schedule_import.dart @@ -8,11 +8,13 @@ class ImportDraft { required this.explicitlyEmpty, required this.source, required this.parserVersion, + this.semesterLabel, }); final List courses; final bool explicitlyEmpty; final CourseSource source; final String parserVersion; + final String? semesterLabel; } class JsonScheduleImport { diff --git a/mobile/lib/data/schedule_file_import.dart b/mobile/lib/data/schedule_file_import.dart new file mode 100644 index 0000000..15ffd2d --- /dev/null +++ b/mobile/lib/data/schedule_file_import.dart @@ -0,0 +1,17 @@ +import 'json_schedule_import.dart'; +import '../domain/course.dart'; +import '../features/school/school_page_adapter.dart'; + +class ScheduleFileImport { + static ImportDraft parse(String content) { + final trimmed = content.replaceFirst('\uFEFF', '').trimLeft(); + if (trimmed.startsWith('<')) { + return SchoolPageAdapter().extract( + trimmed, + Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1'), + source: CourseSource.file, + ); + } + return JsonScheduleImport.parse(trimmed); + } +} diff --git a/mobile/lib/features/profile/import_preview.dart b/mobile/lib/features/profile/import_preview.dart index 2ebe404..096efb6 100644 --- a/mobile/lib/features/profile/import_preview.dart +++ b/mobile/lib/features/profile/import_preview.dart @@ -40,6 +40,7 @@ class _ImportPreviewState extends State { '${draft.courses.length} 条课程安排 · ${draft.source == CourseSource.demo ? '示例体验' : '导入预览'}', style: const TextStyle(color: river), ), + if (draft.semesterLabel != null) Text('学期:${draft.semesterLabel}'), const SizedBox(height: 18), if (empty) Text(draft.explicitlyEmpty ? '此操作会清空当前课表。' : '没有识别到课程,当前课表保持不变。'), diff --git a/mobile/lib/features/profile/profile_page.dart b/mobile/lib/features/profile/profile_page.dart index 497269e..b813634 100644 --- a/mobile/lib/features/profile/profile_page.dart +++ b/mobile/lib/features/profile/profile_page.dart @@ -6,7 +6,7 @@ import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; import '../../app/app_theme.dart'; -import '../../data/json_schedule_import.dart'; +import '../../data/schedule_file_import.dart'; import 'settings_page.dart'; import 'import_preview.dart'; @@ -26,7 +26,7 @@ class ProfilePage extends StatelessWidget { try { final file = await FilePicker.pickFile( type: FileType.custom, - allowedExtensions: ['json'], + allowedExtensions: ['json', 'html', 'htm'], ); if (file == null) return; final bytes = BytesBuilder(copy: false); @@ -38,7 +38,7 @@ class ProfilePage extends StatelessWidget { } bytes.add(chunk); } - final draft = JsonScheduleImport.parse(utf8.decode(bytes.takeBytes())); + final draft = ScheduleFileImport.parse(utf8.decode(bytes.takeBytes())); if (!context.mounted) return; await showModalBottomSheet( context: context, @@ -61,31 +61,16 @@ class ProfilePage extends StatelessWidget { return ListView( padding: EdgeInsets.fromLTRB( 22, - 26, + 10, 22, 145 + MediaQuery.paddingOf(context).bottom, ), children: [ - Text( - 'YOUR LITTLE RIVERSIDE', - style: TextStyle( - fontSize: 10, - letterSpacing: 1.8, - color: river.withValues(alpha: 0.75), - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 10), const Text( - '我的河畔', - style: TextStyle(fontSize: 29, fontWeight: FontWeight.w700), + 'Riverside Schedule', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), ), - const SizedBox(height: 7), - Text( - '把时间,留给真正重要的事。', - style: TextStyle(fontSize: 13, color: ink.withValues(alpha: 0.5)), - ), - const SizedBox(height: 26), + const SizedBox(height: 16), SurfaceCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -207,14 +192,14 @@ class ProfilePage extends StatelessWidget { _tile( Icons.file_download_outlined, '从文件导入', - '先预览,再替换课表', + '学校 HTML / JSON · 先预览再导入', () => _import(context), ), Divider(height: 1, color: ink.withValues(alpha: 0.06)), _tile( Icons.auto_awesome_outlined, '体验示例课表', - '无需登录,看看河畔的样子', + '无需登录,预览课表样式', controller.showDemo, ), ], @@ -225,7 +210,7 @@ class ProfilePage extends StatelessWidget { child: Column( children: [ const Text( - '河畔Everyday', + 'Riverside Schedule', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600), ), const SizedBox(height: 6), diff --git a/mobile/lib/features/profile/settings_page.dart b/mobile/lib/features/profile/settings_page.dart index d8b3e11..aea1cef 100644 --- a/mobile/lib/features/profile/settings_page.dart +++ b/mobile/lib/features/profile/settings_page.dart @@ -78,7 +78,7 @@ class _SettingsPageState extends State { context: context, builder: (context) => AlertDialog( title: const Text('关闭日历同步'), - content: const Text('是否删除河畔创建的课程日程?其他日程不会受影响。'), + content: const Text('是否删除Riverside Schedule 创建的课程日程?其他日程不会受影响。'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), diff --git a/mobile/lib/features/school/school_auth_page.dart b/mobile/lib/features/school/school_auth_page.dart index b3780f3..660a9e0 100644 --- a/mobile/lib/features/school/school_auth_page.dart +++ b/mobile/lib/features/school/school_auth_page.dart @@ -92,7 +92,7 @@ class _SchoolAuthPageState extends State { ), actions: [ IconButton( - tooltip: _showWeb ? '河畔登录界面' : '学校页面', + tooltip: _showWeb ? '应用登录界面' : '学校页面', onPressed: () => setState(() => _showWeb = !_showWeb), icon: Icon( _showWeb ? Icons.person_outline : Icons.language_rounded, @@ -195,7 +195,7 @@ class _SchoolAuthPageState extends State { ), const SizedBox(height: 20), Text( - '密码仅交给学校处理,不在河畔保存。需要验证码或其他验证时,将由学校页面继续处理。', + '密码仅交给学校处理,不在应用中保存。需要验证码或其他验证时,将由学校页面继续处理。', textAlign: TextAlign.center, style: TextStyle( fontSize: 12, diff --git a/mobile/lib/features/school/school_navigation_policy.dart b/mobile/lib/features/school/school_navigation_policy.dart index 909e78a..282592a 100644 --- a/mobile/lib/features/school/school_navigation_policy.dart +++ b/mobile/lib/features/school/school_navigation_policy.dart @@ -12,7 +12,10 @@ class SchoolNavigationPolicy { static bool isTimetable(Uri uri) => _https(uri) && uri.host == 'yjsjy.uestc.edu.cn' && - uri.path == '/pyxx/pygl/xskbcx/index/1'; + RegExp( + r'^/pyxx/pygl/xskbcx/index/[1-9]\d*' + r'$', + ).hasMatch(uri.path); static bool isSchoolPage(Uri uri) => _https(uri) && const {'idas.uestc.edu.cn', 'yjsjy.uestc.edu.cn'}.contains(uri.host); diff --git a/mobile/lib/features/school/school_page_adapter.dart b/mobile/lib/features/school/school_page_adapter.dart index 267ad7e..8405b78 100644 --- a/mobile/lib/features/school/school_page_adapter.dart +++ b/mobile/lib/features/school/school_page_adapter.dart @@ -1,16 +1,177 @@ +import 'dart:convert'; + +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as html; + import '../../data/json_schedule_import.dart'; +import '../../domain/course.dart'; import 'school_navigation_policy.dart'; class UnsupportedSchoolLayout implements Exception { @override - String toString() => '学校课表识别待联调,当前课表保持不变。'; + String toString() => '未识别到完整的学校课表,当前课表保持不变。请在课表查询页面保存 HTML 后重试。'; } +/// Reads the supplied graduate-system table as data; never executes its scripts. class SchoolPageAdapter { - ImportDraft extract(String html, Uri source) { - // A real authenticated timetable fixture is required before registering a parser. - // Unknown HTML must never erase the student's last valid imported timetable. - throw UnsupportedSchoolLayout(); + ImportDraft extract( + String content, + Uri origin, { + CourseSource source = CourseSource.school, + }) { + try { + if (!SchoolNavigationPolicy.isTimetable(origin) || + source == CourseSource.demo || + utf8.encode(content).length > 2 * 1024 * 1024) { + throw UnsupportedSchoolLayout(); + } + final document = html.parse(content); + final table = document.querySelector('#tbl'); + final selected = document.querySelector('#drpxq option[selected]'); + final term = selected?.attributes['value']; + if (table == null || + term == null || + !RegExp(r'^\d+$').hasMatch(term) || + selected!.text.trim().isEmpty) { + throw UnsupportedSchoolLayout(); + } + final rows = table.querySelectorAll('tr'); + const weekdays = ['节次', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日']; + if (rows.length != 6 || rows.first.children.length != 8) { + throw UnsupportedSchoolLayout(); + } + for (var i = 0; i < 8; i++) { + if (rows.first.children[i].text.trim() != weekdays[i]) { + throw UnsupportedSchoolLayout(); + } + } + final courses = {}; + final ids = {}; + const starts = [1, 3, 5, 7, 9]; + for (var row = 0; row < starts.length; row++) { + final first = starts[row], last = first == 9 ? 11 : first + 1; + final cells = rows[row + 1].children; + if (cells.length != 8 || + cells.first.id != 't0$first' || + cells.first.text.trim() != '$first-$last节') { + throw UnsupportedSchoolLayout(); + } + for (var day = 1; day <= 7; day++) { + final cell = cells[day]; + if (cell.id != 't$day$first') throw UnsupportedSchoolLayout(); + for (final line in _lines(cell)) { + final record = RegExp( + r'^(\d+)/(.+?)/(\d+(?:\.\d+)?)/(\d+(?:\.\d+)?)/([^/]+)/([^/]+)周/(\d+)[~~-](\d+)节/(.+)$', + ).firstMatch(line); + if (record == null || + int.parse(record[7]!) != first || + int.parse(record[8]!) != last) { + throw UnsupportedSchoolLayout(); + } + final code = record[1]!, + name = record[2]!.trim(), + teacher = record[5]!.trim(), + room = record[9]!.trim(); + final weeks = _weeks(record[6]!); + final identity = jsonEncode([ + term, + code, + name, + teacher, + room, + day, + first, + last, + ]); + final previous = courses[identity]; + final id = + previous?.id ?? + 'uestc-$term-$code-$day-$first-$last-${_hash(identity)}'; + if (previous == null && !ids.add(id)) { + throw UnsupportedSchoolLayout(); + } + courses[identity] = Course( + id: id, + name: name, + teacher: teacher, + room: room, + weekday: day, + startPeriod: first, + endPeriod: last, + weeks: {...?previous?.weeks, ...weeks}, + source: source, + ); + } + } + } + // An empty DOM can be an expired session or an incomplete response. + if (courses.isEmpty) throw UnsupportedSchoolLayout(); + return ImportDraft( + courses: List.unmodifiable(courses.values), + explicitlyEmpty: false, + source: source, + parserVersion: 'uestc-graduate-html-v1', + semesterLabel: selected.text.trim(), + ); + } on UnsupportedSchoolLayout { + rethrow; + } catch (_) { + throw UnsupportedSchoolLayout(); + } + } + + List _lines(dom.Element cell) { + final buffer = StringBuffer(); + void visit(dom.Node node) { + if (node is dom.Text) { + buffer.write(node.text); + } else if (node is dom.Element) { + if (node.localName == 'br') { + buffer.write('\n'); + } else { + if (node.localName == 'script' || node.localName == 'style') { + throw UnsupportedSchoolLayout(); + } + for (final child in node.nodes) { + visit(child); + } + } + } + } + + for (final node in cell.nodes) { + visit(node); + } + return buffer + .toString() + .split('\n') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + } + + Set _weeks(String text) { + final result = {}; + for (final part in text.split(RegExp('[,,、]'))) { + final range = RegExp(r'^\s*(\d+)(?:\s*[-~~]\s*(\d+))?\s*$') + .firstMatch(part); + if (range == null) throw UnsupportedSchoolLayout(); + final first = int.parse(range[1]!), + last = int.parse(range[2] ?? range[1]!); + if (first < 1 || last > 30 || last < first) { + throw UnsupportedSchoolLayout(); + } + result.addAll(List.generate(last - first + 1, (i) => first + i)); + } + return result; + } + + String _hash(String value) { + var hash = 2166136261; + for (final byte in utf8.encode(value)) { + hash = ((hash ^ byte) * 16777619) & 0xffffffff; + } + return hash.toRadixString(16).padLeft(8, '0'); } } @@ -18,17 +179,36 @@ class SchoolReader { SchoolReader(this.adapter); final SchoolPageAdapter adapter; Future? _pending; + int _generation = 0; + Uri? _origin; bool get busy => _pending != null; + void invalidate() { + _generation++; + _pending = null; + _origin = null; + } + Future read(Uri origin, Future Function() getHtml) { + if (!SchoolNavigationPolicy.isTimetable(origin) || + (_pending != null && origin != _origin)) { + return Future.error(UnsupportedSchoolLayout()); + } if (_pending != null) return _pending!; + final generation = _generation; + _origin = origin; _pending = Future(() async { try { if (!SchoolNavigationPolicy.isTimetable(origin)) { throw UnsupportedSchoolLayout(); } - return adapter.extract(await getHtml(), origin); + final content = await getHtml(); + if (generation != _generation) throw UnsupportedSchoolLayout(); + return adapter.extract(content, origin); } finally { - _pending = null; + if (generation == _generation) { + _pending = null; + _origin = null; + } } }); return _pending!; diff --git a/mobile/lib/features/school/school_session.dart b/mobile/lib/features/school/school_session.dart index 26771dd..4ca09df 100644 --- a/mobile/lib/features/school/school_session.dart +++ b/mobile/lib/features/school/school_session.dart @@ -35,6 +35,9 @@ class SchoolSession extends ChangeNotifier { return NavigationDecision.navigate; }, onPageStarted: (_) { + _pageGeneration++; + _reader.invalidate(); + pendingDraft = null; if (!hasOpened) { return; } @@ -72,6 +75,7 @@ class SchoolSession extends ChangeNotifier { hasOpened = false, formReady = false; bool get busy => _loading || _submitting || _reader.busy; + int _pageGeneration = 0; ImportDraft? pendingDraft; String message = '请通过学校统一身份认证登录。'; String get statusLabel => switch (connectionState) { @@ -79,7 +83,7 @@ class SchoolSession extends ChangeNotifier { SchoolConnectionState.authenticating => '请在学校页面完成认证', SchoolConnectionState.connected => '学校认证已确认', SchoolConnectionState.expired => '学校登录已过期', - SchoolConnectionState.unknown => '学校页面已打开 · 课表识别待联调', + SchoolConnectionState.unknown => '学校页面已打开 · 等待完整课表', }; static final authUrl = Uri.parse( 'https://idas.uestc.edu.cn/authserver/login', @@ -134,26 +138,33 @@ class SchoolSession extends ChangeNotifier { return; } _loading = false; + final generation = _pageGeneration; final uri = Uri.tryParse(url); if (uri != null && SchoolNavigationPolicy.canInjectCredentials(uri)) { connectionState = connectionState == SchoolConnectionState.connected ? SchoolConnectionState.expired : SchoolConnectionState.authenticating; try { - formReady = _truth( + final ready = _truth( await web.runJavaScriptReturningResult(_readyScript), ); + if (!hasOpened || generation != _pageGeneration) return; + formReady = ready; } catch (_) { + if (!hasOpened || generation != _pageGeneration) return; formReady = false; } - message = formReady ? '可以使用河畔界面登录;验证码会在学校页面完成。' : '请使用学校原页面完成认证。'; + message = formReady ? '可以使用应用界面登录;验证码会在学校页面完成。' : '请使用学校原页面完成认证。'; } else if (uri != null && SchoolNavigationPolicy.isTimetable(uri)) { connectionState = SchoolConnectionState.unknown; message = '已打开课表页面,正在核对课程结构。'; try { - pendingDraft = await readTimetable(); + final draft = await readTimetable(); + if (!hasOpened || generation != _pageGeneration) return; + pendingDraft = draft; connectionState = SchoolConnectionState.connected; } catch (error) { + if (!hasOpened || generation != _pageGeneration) return; message = error is UnsupportedSchoolLayout ? error.toString() : '课表读取未完成,已有课表保持不变。'; @@ -238,6 +249,8 @@ class SchoolSession extends ChangeNotifier { } Future logout() async { + _pageGeneration++; + _reader.invalidate(); hasOpened = false; await WebViewCookieManager().clearCookies(); await web.clearCache(); diff --git a/mobile/lib/features/timetable/timetable_page.dart b/mobile/lib/features/timetable/timetable_page.dart index fc8e846..73e2558 100644 --- a/mobile/lib/features/timetable/timetable_page.dart +++ b/mobile/lib/features/timetable/timetable_page.dart @@ -33,57 +33,23 @@ class _TimetablePageState extends State { final sunday = monday.add(const Duration(days: 6)); return ListView( padding: EdgeInsets.fromLTRB( - 18, - 20, - 18, + 8, + 10, + 8, 140 + MediaQuery.paddingOf(context).bottom, ), children: [ - Text( - 'HEPAN / EVERYDAY', + const Text( + 'Riverside Schedule', style: TextStyle( - fontSize: 10, - letterSpacing: 2.2, - color: river.withValues(alpha: 0.8), + fontSize: 22, fontWeight: FontWeight.w700, + letterSpacing: -0.5, ), ), - const SizedBox(height: 9), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Expanded( - child: Text( - '每一天,\n从容一点。', - style: TextStyle( - fontSize: 27, - fontWeight: FontWeight.w700, - height: 1.3, - letterSpacing: -0.6, - ), - ), - ), - Container( - margin: const EdgeInsets.only(top: 5), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - ), - child: Text( - '${today.month} 月 ${today.day} 日', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: river, - ), - ), - ), - ], - ), - const SizedBox(height: 22), + const SizedBox(height: 10), SurfaceCard( - padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0), child: Row( children: [ IconButton( @@ -126,7 +92,7 @@ class _TimetablePageState extends State { ], ), ), - const SizedBox(height: 14), + const SizedBox(height: 4), if (c.isDemo) Padding( padding: const EdgeInsets.only(bottom: 12), @@ -172,7 +138,7 @@ class _TimetablePageState extends State { ), const SizedBox(height: 12), Text( - '左右滑动查看整周 · 点击课程查看详情', + '点击课程查看完整信息', textAlign: TextAlign.center, style: TextStyle(fontSize: 11, color: ink.withValues(alpha: 0.35)), ), @@ -191,7 +157,7 @@ class _TimetablePageState extends State { ), const SizedBox(height: 12), Text( - '在「我的」设置学期并导入课程,\n或先看看河畔的示例课表。', + '在「我的」设置学期并导入课程,\n或先看看示例课表。', textAlign: TextAlign.center, style: TextStyle( fontSize: 13, diff --git a/mobile/lib/features/timetable/week_grid.dart b/mobile/lib/features/timetable/week_grid.dart index ac633cb..9a01772 100644 --- a/mobile/lib/features/timetable/week_grid.dart +++ b/mobile/lib/features/timetable/week_grid.dart @@ -18,213 +18,18 @@ class WeekGrid extends StatelessWidget { final List events; final DateTime monday, today; final AppSettings settings; - @override - Widget build(BuildContext context) { - final scale = MediaQuery.textScalerOf(context).scale(12) / 12; - final rowHeight = 78.0 * scale.clamp(1, 2.5), - gap = 20.0, - header = 56.0 * scale.clamp(1, 2); - double top(int period) => - (period - 1) * rowHeight + - (period > 4 ? gap : 0) + - (period > 8 ? gap : 0); - final height = 11 * rowHeight + 2 * gap; - return LayoutBuilder( - builder: (context, constraints) { - final columnWidth = math.max( - 90.0 * scale.clamp(1, 1.4), - (constraints.maxWidth - 52) / 7, - ); - final gridWidth = columnWidth * 7; - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 52, - height: header + height, - child: Stack( - children: [ - for (final slot in lessonSlots) - Positioned( - top: header + top(slot.period), - left: 0, - right: 0, - height: rowHeight, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${slot.period}', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 5), - Text( - minuteLabel(slot.startMinute), - style: TextStyle( - fontSize: 9, - color: ink.withValues(alpha: 0.5), - ), - ), - Text( - minuteLabel(slot.endMinute), - style: TextStyle( - fontSize: 9, - color: ink.withValues(alpha: 0.35), - ), - ), - ], - ), - ), - ], - ), - ), - Expanded( - child: SingleChildScrollView( - key: const Key('week-horizontal'), - scrollDirection: Axis.horizontal, - child: SizedBox( - width: gridWidth, - child: Column( - children: [ - SizedBox( - height: header, - child: Row( - children: List.generate(7, (i) { - final date = monday.add(Duration(days: i)); - final active = - date.year == today.year && - date.month == today.month && - date.day == today.day; - return SizedBox( - width: columnWidth, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - const [ - '周一', - '周二', - '周三', - '周四', - '周五', - '周六', - '周日', - ][i], - style: TextStyle( - fontSize: 12, - color: active - ? river - : ink.withValues(alpha: 0.55), - ), - ), - const SizedBox(height: 4), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 9, - vertical: 3, - ), - decoration: BoxDecoration( - color: active - ? river - : Colors.transparent, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - '${date.day}', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: active ? Colors.white : ink, - ), - ), - ), - ], - ), - ); - }), - ), - ), - SizedBox( - height: height, - child: Stack( - children: [ - for (var i = 0; i < 7; i++) - Positioned( - left: i * columnWidth, - top: 0, - width: columnWidth, - height: height, - child: DecoratedBox( - decoration: BoxDecoration( - color: i < 5 - ? Colors.white.withValues(alpha: 0.45) - : river.withValues(alpha: 0.025), - border: Border( - left: BorderSide( - color: ink.withValues(alpha: 0.035), - ), - ), - ), - ), - ), - for (var p = 1; p <= 11; p++) - Positioned( - left: 0, - right: 0, - top: top(p), - child: Divider( - height: 1, - color: ink.withValues(alpha: 0.05), - ), - ), - for (var day = 1; day <= 7; day++) - ..._cards( - context, - day, - columnWidth, - rowHeight, - top, - ), - ], - ), - ), - ], - ), - ), - ), - ), - ], - ); - }, - ); - } + static const _nameStyle = TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w700, + height: 1.25, + ); + static const _metadataStyle = TextStyle(fontSize: 9.5, height: 1.22); - List _cards( - BuildContext context, - int day, - double width, - double rowHeight, - double Function(int) top, - ) { - final values = events.where((e) => e.course.weekday == day).toList(); - return [ - for (final e in values) - _position(context, e, values, width, rowHeight, top), - ]; - } - - Widget _position( - BuildContext context, - CourseOccurrence e, - List values, - double width, - double rowHeight, - double Function(int) top, - ) { - final connected = {e}; + List _group(CourseOccurrence event) { + final values = events + .where((e) => e.course.weekday == event.course.weekday) + .toList(); + final connected = {event}; var grew = true; while (grew) { grew = false; @@ -236,74 +41,330 @@ class WeekGrid extends StatelessWidget { } } } - final group = values.where(connected.contains).toList(); - final lane = group.indexOf(e), laneWidth = width / group.length; - return Positioned( - left: (e.course.weekday - 1) * width + lane * laneWidth + 3, - top: top(e.course.startPeriod) + 4, - width: math.max(12, laneWidth - 6), - height: - top(e.course.endPeriod) + rowHeight - top(e.course.startPeriod) - 8, - child: Semantics( - label: '${e.course.name},${e.course.teacher},${e.course.room}', - button: true, - child: GestureDetector( - onTap: () => showCourseDetails(context, e), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: courseColor(e.course.id, settings), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: Colors.white.withValues(alpha: 0.8)), + return values.where(connected.contains).toList(); + } + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final scaler = MediaQuery.textScalerOf(context); + final scale = scaler.scale(10) / 10; + const gutter = 34.0, gap = 16.0; + final width = (constraints.maxWidth - gutter) / 7; + final header = 56.0 * scale.clamp(1, 2.5); + final groups = {for (final e in events) e: _group(e)}; + final defaultStyle = DefaultTextStyle.of(context).style; + double measure( + String value, + TextStyle style, + double available, { + int? lines, + }) { + final painter = TextPainter( + text: TextSpan(text: value, style: defaultStyle.merge(style)), + textDirection: Directionality.of(context), + textScaler: scaler, + maxLines: lines, + ellipsis: lines == null ? null : '…', + )..layout(maxWidth: available); + final result = painter.height; + painter.dispose(); + return result; + } + + // A single shared lesson height keeps equal-duration cards identical in size. + // Increase that height for full metadata, never stretch one card independently. + var rowHeight = 54.0 * scale.clamp(1, 2.5); + for (final e in events) { + final group = groups[e]!; + if (group.first != e) continue; + final available = math.max(1.0, width - 12); + var entryHeight = 0.0; + for (final value in group) { + final required = + measure(value.course.name, _nameStyle, available, lines: 2) + + measure(value.course.teacher, _metadataStyle, available) + + measure(value.course.room, _metadataStyle, available) + + 21; + final periodHeight = group.length > 1 + ? measure( + '${value.course.startPeriod}–${value.course.endPeriod}节', + _metadataStyle, + available, + ) + + 3 + : 0.0; + entryHeight = math.max(entryHeight, required + periodHeight); + } + final first = group.map((v) => v.course.startPeriod).reduce(math.min); + final last = group.map((v) => v.course.endPeriod).reduce(math.max); + final warning = group.length > 1 + ? measure('冲突列表', _metadataStyle, width - 12) + 8 + : 0.0; + rowHeight = math.max( + rowHeight, + (entryHeight * group.length + warning + 8) / (last - first + 1), + ); + } + rowHeight = rowHeight.ceilToDouble(); + double top(int period) => + (period - 1) * rowHeight + + (period > 4 ? gap : 0) + + (period > 8 ? gap : 0); + final height = 11 * rowHeight + 2 * gap; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: gutter, + height: header + height, + child: Stack( + children: [ + for (final slot in lessonSlots) + Positioned( + top: header + top(slot.period), + left: 0, + right: 3, + height: rowHeight, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${slot.period}', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + FittedBox( + child: Text( + minuteLabel(slot.startMinute), + style: TextStyle( + fontSize: 8, + color: ink.withValues(alpha: 0.55), + ), + ), + ), + FittedBox( + child: Text( + minuteLabel(slot.endMinute), + style: TextStyle( + fontSize: 8, + color: ink.withValues(alpha: 0.4), + ), + ), + ), + ], + ), + ), + ], ), - child: SingleChildScrollView( - physics: const NeverScrollableScrollPhysics(), + ), + Expanded( + child: Column( + children: [ + SizedBox( + height: header, + child: Row( + children: List.generate(7, (i) { + final date = monday.add(Duration(days: i)); + final active = + date.year == today.year && + date.month == today.month && + date.day == today.day; + return SizedBox( + width: width, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + const [ + '周一', + '周二', + '周三', + '周四', + '周五', + '周六', + '周日', + ][i], + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + color: active + ? river + : ink.withValues(alpha: 0.6), + ), + ), + const SizedBox(height: 3), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 2, + ), + decoration: BoxDecoration( + color: active ? river : Colors.transparent, + borderRadius: BorderRadius.circular(9), + ), + child: Text( + '${date.day}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: active ? Colors.white : ink, + ), + ), + ), + ], + ), + ); + }), + ), + ), + SizedBox( + height: height, + child: Stack( + children: [ + for (var i = 0; i < 7; i++) + Positioned( + left: i * width, + top: 0, + width: width, + height: height, + child: DecoratedBox( + decoration: BoxDecoration( + color: i < 5 + ? Colors.white.withValues(alpha: 0.45) + : river.withValues(alpha: 0.025), + border: Border( + left: BorderSide( + color: ink.withValues(alpha: 0.04), + ), + ), + ), + ), + ), + for (var p = 1; p <= 11; p++) + Positioned( + left: 0, + right: 0, + top: top(p), + child: Divider( + height: 1, + color: ink.withValues(alpha: 0.055), + ), + ), + for (final e in events) + if (groups[e]!.first == e) + _block(context, groups[e]!, width, rowHeight, top), + ], + ), + ), + ], + ), + ), + ], + ); + }, + ); + + Widget _block( + BuildContext context, + List group, + double width, + double rowHeight, + double Function(int) top, + ) { + final first = group.map((e) => e.course.startPeriod).reduce(math.min); + final last = group.map((e) => e.course.endPeriod).reduce(math.max); + return Positioned( + left: (group.first.course.weekday - 1) * width + 2, + top: top(first) + 4, + width: width - 4, + height: (last - first + 1) * rowHeight - 8, + child: group.length == 1 + ? _entry(context, group.first) + : Container( + decoration: BoxDecoration( + color: river.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: river.withValues(alpha: 0.15)), + ), + // Conflict entries are explicitly a list, with their own lesson ranges. + // They are not separate time-scaled bars squeezed into narrow lanes. child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (group.length > 1) - const Text( - '时间冲突', - style: TextStyle(fontSize: 9, color: Color(0xFFB45D54)), - ), - Text( - e.course.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - height: 1.25, - ), - ), - const SizedBox(height: 7), - Text( - e.course.teacher, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10, - color: ink.withValues(alpha: 0.7), - ), - ), - const SizedBox(height: 3), - Text( - e.course.room, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10, - color: ink.withValues(alpha: 0.6), - height: 1.2, + const Padding( + padding: EdgeInsets.symmetric(horizontal: 3, vertical: 3), + child: Text( + '冲突列表', + style: TextStyle( + fontSize: 9.5, + height: 1.22, + color: Color(0xFFB45D54), + ), ), ), + for (final e in group) + Expanded(child: _entry(context, e, inConflict: true)), ], ), ), - ), - ), - ), ); } + + Widget _entry( + BuildContext context, + CourseOccurrence e, { + bool inConflict = false, + }) => Semantics( + label: '${e.course.name},${e.course.teacher},${e.course.room}', + button: true, + excludeSemantics: true, + onTap: () => showCourseDetails(context, e), + child: GestureDetector( + onTap: () => showCourseDetails(context, e), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 6), + decoration: inConflict + ? null + : BoxDecoration( + color: courseColor(e.course.id, settings), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white.withValues(alpha: 0.8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + e.course.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: _nameStyle, + ), + if (inConflict) ...[ + const SizedBox(height: 3), + Text( + '${e.course.startPeriod}–${e.course.endPeriod}节', + style: _metadataStyle, + ), + ], + const SizedBox(height: 4), + Text( + e.course.teacher, + style: _metadataStyle.copyWith( + color: ink.withValues(alpha: 0.85), + ), + ), + const SizedBox(height: 3), + Text( + e.course.room, + style: _metadataStyle.copyWith(color: ink.withValues(alpha: 0.7)), + ), + ], + ), + ), + ), + ); } diff --git a/mobile/lib/services/android_calendar_service.dart b/mobile/lib/services/android_calendar_service.dart index 5cf5a22..ed0b0d3 100644 --- a/mobile/lib/services/android_calendar_service.dart +++ b/mobile/lib/services/android_calendar_service.dart @@ -87,7 +87,7 @@ class AndroidCalendarService extends CalendarService { calendarId: calendarId, title: occurrence.course.name, room: occurrence.course.room, - description: '任课老师:${occurrence.course.teacher}\n河畔Everyday', + description: '任课老师:${occurrence.course.teacher}\nRiverside Schedule', startMs: occurrence.start.millisecondsSinceEpoch, endMs: occurrence.end.millisecondsSinceEpoch, ), diff --git a/mobile/lib/widgets/glass_dock.dart b/mobile/lib/widgets/glass_dock.dart index 1547f0e..38cd657 100644 --- a/mobile/lib/widgets/glass_dock.dart +++ b/mobile/lib/widgets/glass_dock.dart @@ -2,9 +2,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -import '../app/app_theme.dart'; - -class GlassDock extends StatelessWidget { +class GlassDock extends StatefulWidget { const GlassDock({ super.key, required this.selectedIndex, @@ -12,74 +10,191 @@ class GlassDock extends StatelessWidget { }); final int selectedIndex; final ValueChanged onSelected; + @override + State createState() => _GlassDockState(); +} + +class _GlassDockState extends State { + static const _width = 192.0, _travel = 88.0; + double? _drag; + double get _position => _drag ?? widget.selectedIndex.toDouble(); + void _select(int index) { + setState(() => _drag = null); + widget.onSelected(index); + } + @override Widget build(BuildContext context) => Container( + width: _width, + height: 70, decoration: BoxDecoration( borderRadius: BorderRadius.circular(40), boxShadow: [ BoxShadow( - color: ink.withValues(alpha: 0.13), + color: const Color(0xFF10212E).withValues(alpha: 0.22), blurRadius: 28, offset: const Offset(0, 10), ), + BoxShadow( + color: Colors.white.withValues(alpha: 0.35), + blurRadius: 1, + spreadRadius: 1, + ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(40), child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 22, sigmaY: 22), - child: Container( - width: 166, - height: 68, - padding: const EdgeInsets.all(7), + filter: ImageFilter.blur(sigmaX: 24, sigmaY: 24), + child: DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(40), gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - Colors.white.withValues(alpha: 0.85), - Colors.white.withValues(alpha: 0.5), + const Color(0xFFB9C0CA).withValues(alpha: 0.58), + const Color(0xFF56616D).withValues(alpha: 0.34), + const Color(0xFFA1ADB8).withValues(alpha: 0.42), ], ), border: Border.all( - color: Colors.white.withValues(alpha: 0.95), + color: Colors.white.withValues(alpha: 0.55), width: 1.2, ), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + child: Stack( children: [ - _item(0, Icons.calendar_month_rounded, '课表'), - _item(1, Icons.person_outline_rounded, '我的'), + Positioned( + left: 18, + right: 18, + top: 1, + child: Container( + height: 1.5, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30), + gradient: LinearGradient( + colors: [ + Colors.white.withValues(alpha: 0), + Colors.white.withValues(alpha: 0.8), + Colors.white.withValues(alpha: 0.08), + ], + ), + ), + ), + ), + AnimatedPositioned( + duration: Duration(milliseconds: _drag == null ? 260 : 0), + curve: Curves.easeOutCubic, + left: 7 + _position * _travel, + top: _drag == null ? 7 : 5, + width: _drag == null ? 90 : 94, + height: _drag == null ? 56 : 60, + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(36), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + const Color(0xFF24323F).withValues(alpha: 0.64), + const Color(0xFF101E2B).withValues(alpha: 0.8), + const Color(0xFF475866).withValues(alpha: 0.5), + ], + ), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 0.9, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 7, + offset: const Offset(0, 3), + ), + BoxShadow( + color: Colors.white.withValues(alpha: 0.12), + blurRadius: 3, + spreadRadius: 0.5, + ), + ], + ), + ), + ), + ), + Positioned( + left: 8, + right: 8, + top: 7, + bottom: 7, + child: Row( + children: [ + _item(0, Icons.calendar_month_rounded, '课表'), + _item(1, Icons.person_rounded, '我的'), + ], + ), + ), + Positioned( + left: 16, + right: 16, + bottom: 1, + child: Container( + height: 1.3, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.white.withValues(alpha: 0.05), + Colors.white.withValues(alpha: 0.5), + Colors.white.withValues(alpha: 0.05), + ], + ), + ), + ), + ), ], ), ), ), ), ); - Widget _item(int index, IconData icon, String label) => Semantics( - label: label, - button: true, - selected: index == selectedIndex, - excludeSemantics: true, - child: GestureDetector( - onTap: () => onSelected(index), - behavior: HitTestBehavior.opaque, - child: AnimatedContainer( - duration: const Duration(milliseconds: 220), - width: 66, - height: 52, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(30), - color: index == selectedIndex - ? river.withValues(alpha: 0.12) - : Colors.transparent, - ), - child: Icon( - icon, - size: 27, - color: index == selectedIndex ? river : ink.withValues(alpha: 0.45), + Widget _item(int index, IconData icon, String label) => Expanded( + child: Semantics( + label: label, + button: true, + selected: index == widget.selectedIndex, + excludeSemantics: true, + onTap: () => _select(index), + child: GestureDetector( + key: index == widget.selectedIndex ? const Key('dock-cursor') : null, + behavior: HitTestBehavior.opaque, + onTap: () => _select(index), + // Only the selected lens can start a drag. The pages have no swipe handlers. + onHorizontalDragStart: index == widget.selectedIndex + ? (_) => setState(() => _drag = widget.selectedIndex.toDouble()) + : null, + onHorizontalDragUpdate: index == widget.selectedIndex + ? (details) => setState( + () => _drag = (_position + details.delta.dx / _travel).clamp( + 0, + 1, + ), + ) + : null, + onHorizontalDragEnd: index == widget.selectedIndex + ? (_) => _select(_position >= 0.5 ? 1 : 0) + : null, + onHorizontalDragCancel: index == widget.selectedIndex + ? () => setState(() => _drag = null) + : null, + child: Center( + child: Icon( + icon, + size: 27, + color: index == (_position >= 0.5 ? 1 : 0) + ? const Color(0xFF458BFF) + : Colors.white.withValues(alpha: 0.85), + ), ), ), ), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 18b202e..fa6206e 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" dbus: dependency: transitive description: @@ -247,6 +255,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + html: + dependency: "direct main" + description: + name: html + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" + url: "https://pub.dev" + source: hosted + version: "0.15.7" http: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 7835755..5afe05c 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,7 +1,7 @@ name: hepan_everyday -description: 河畔Everyday研究生课表 +description: Riverside Schedule 研究生课表 publish_to: none -version: 0.1.0+1 +version: 0.2.0+2 environment: sdk: ^3.13.5 dependencies: @@ -12,6 +12,7 @@ dependencies: webview_flutter: 4.14.1 flutter_local_notifications: 22.3.2 timezone: 0.11.1 + html: 0.15.7 path_provider: 2.1.6 file_picker: 13.1.0 dev_dependencies: diff --git a/mobile/test/fixtures/school/README.md b/mobile/test/fixtures/school/README.md index 33f2436..9db6324 100644 --- a/mobile/test/fixtures/school/README.md +++ b/mobile/test/fixtures/school/README.md @@ -1,7 +1,5 @@ -# 学校页面验证状态 +# 学校页面测试样本 -当前没有登录后的研究生课表样本,也没有以人工构造表格冒充学校样本。 +已根据2026-10-13提供的课表HTML实现SchoolPageAdapter。自动化测试中的fixture()只合成星期列、节次表格和课程记录,不含本人姓名、学号、脚本或登录信息。 -2026-10-12 已核对公开认证页面:密码表单位于 `.login-main #pwdFromId`,使用 username/passwordText 输入、saltPassword 隐藏加密字段、pwdEncryptSalt 与 startLogin/checkForm/encryptPassword 的学校原流程。自定义界面只填充此经过运行时检查的表单,并等待学校验证码检查结束后触发学校登录按钮。表单不匹配时回到学校页面。 - -实际账号认证、验证码、研究生 SSO 跳转与课表识别还需本人设备登录验证。无需在聊天中提供账号密码。SchoolPageAdapter 当前拒绝全部未验证布局,保持已导入的离线课表不变。 +本机可设置RIVERSIDE_HTML_FIXTURE环境变量指向本人保存的HTML,验证7门课程和11条安排;此文件不复制进源码或安装包。实时账号认证、验证码和研究生SSO仍需要本人设备验证。 diff --git a/mobile/test/school/import_boundaries_test.dart b/mobile/test/school/import_boundaries_test.dart new file mode 100644 index 0000000..a76da34 --- /dev/null +++ b/mobile/test/school/import_boundaries_test.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/features/school/school_page_adapter.dart'; +import 'package:hepan_everyday/features/school/school_navigation_policy.dart'; +import 'package:hepan_everyday/data/schedule_file_import.dart'; +import 'package:hepan_everyday/domain/course.dart'; + +import 'recognized_timetable_test.dart' show fixture; + +void main() { + final target = Uri.parse( + 'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1', + ); + test('in-flight read cannot authorize a different origin', () async { + final reader = SchoolReader(SchoolPageAdapter()), + body = Completer(); + final valid = reader.read(target, () => body.future); + final rejected = reader.read( + Uri.parse('https://evil.example/pyxx/pygl/xskbcx/index/1'), + () async => fixture(), + ); + body.complete(fixture()); + await expectLater(rejected, throwsA(isA())); + await valid; + }); + test('invalidated read cannot become a fresh authenticated draft', () async { + final reader = SchoolReader(SchoolPageAdapter()), + body = Completer(); + final stale = reader.read(target, () => body.future); + final check = expectLater(stale, throwsA(isA())); + reader.invalidate(); + final fresh = await reader.read(target, () async => fixture()); + body.complete(fixture()); + await check; + expect(fresh.courses.length, 4); + expect(reader.busy, isFalse); + }); + test('official numeric semester routes are allowed but alternate endpoints are not', () { + expect( + SchoolNavigationPolicy.isTimetable( + Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/73'), + ), + isTrue, + ); + for (final url in [ + 'http://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1', + 'https://user@yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1', + 'https://yjsjy.uestc.edu.cn:444/pyxx/pygl/xskbcx/index/1', + 'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/73/other', + 'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/73evil', + ]) { + expect(SchoolNavigationPolicy.isTimetable(Uri.parse(url)), isFalse); + } + }); + test('BOM saved HTML file imports without claiming live authentication', () { + final draft = ScheduleFileImport.parse('\uFEFF${fixture()}'); + expect(draft.courses.length, 4); + expect(draft.semesterLabel, '2026-2027秋'); + expect(draft.source, CourseSource.file); + expect(draft.courses.every((c) => c.source == CourseSource.file), isTrue); + expect( + () => ScheduleFileImport.parse('登录'), + throwsA(isA()), + ); + }); +} diff --git a/mobile/test/school/recognized_timetable_test.dart b/mobile/test/school/recognized_timetable_test.dart new file mode 100644 index 0000000..b1ef899 --- /dev/null +++ b/mobile/test/school/recognized_timetable_test.dart @@ -0,0 +1,84 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/features/school/school_page_adapter.dart'; +import 'package:hepan_everyday/domain/course.dart'; + +String fixture() { + const records = { + 't51': '101/示例集成电路设计(1班)/32/2/示例教师甲/1-3周/1~2节/示例楼B212
101/示例集成电路设计(1班)/32/2/示例教师甲/6-6周/1~2节/示例楼B212
101/示例集成电路设计(1班)/32/2/示例教师甲/7-9周/1~2节/示例楼B212', + 't13': '101/示例集成电路设计(1班)/32/2/示例教师甲/1-5周/3~4节/示例楼B212', + 't23': '102/示例信号检测与估计(2班)/32/2/示例教师乙、示例教师丙/1-5周/3~4节/示例楼B102
102/示例信号检测与估计(2班)/32/2/示例教师乙、示例教师丙/7-9周/3~4节/示例楼B102', + 't29': '103/示例现代化理论与实践(3班)/36/2/示例教师丁/1-12周/9~11节/示例楼B107', + }; + return '${['一', '二', '三', '四', '五', '六', '日'].map((d) => '').join()}${[1, 3, 5, 7, 9].map((p) => '${List.generate(7, (i) => '').join()}').join()}
节次星期$d
$p-${p == 9 ? 11 : p + 1}节${records['t${i + 1}$p'] ?? ''}
'; +} + +void main() { + final url = Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1'); + test('school DOM merges week segments without losing full metadata or evening periods', () { + final draft = SchoolPageAdapter().extract(fixture(), url); + expect(draft.courses.length, 4); + final friday = draft.courses.singleWhere((c) => c.weekday == 5); + expect(friday.weeks, {1, 2, 3, 6, 7, 8, 9}); + final signal = draft.courses.singleWhere( + (c) => c.weekday == 2 && c.startPeriod == 3, + ); + expect(signal.teacher, '示例教师乙、示例教师丙'); + expect(signal.room, '示例楼B102'); + final evening = draft.courses.singleWhere((c) => c.startPeriod == 9); + expect(evening.endPeriod, 11); + expect(draft.source, CourseSource.school); + expect(draft.explicitlyEmpty, isFalse); + expect( + SchoolPageAdapter().extract(fixture(), url).courses.map((c) => c.id), + draft.courses.map((c) => c.id), + ); + }); + test('invalid cell, missing weekday, mismatched period and empty grid fail closed', () { + for (final body in [ + fixture().replaceFirst('1-3周', '未知周'), + fixture().replaceFirst('id="t71"', 'id="missing"'), + fixture().replaceFirst('1~2节', '5~6节'), + fixture().replaceAll(RegExp(r'\d+/示例[^<]+'), ''), + fixture().replaceFirst('星期日', '星期八'), + ]) { + expect( + () => SchoolPageAdapter().extract(body, url), + throwsA(isA()), + ); + } + }); + test('saved HTML uses file provenance and does not authenticate a school session', () { + final draft = SchoolPageAdapter().extract( + fixture(), + url, + source: CourseSource.file, + ); + expect(draft.source, CourseSource.file); + expect(draft.courses.every((c) => c.source == CourseSource.file), isTrue); + }); + test('actual attached HTML imports eleven arrangements (local optional verification)', () { + final file = File( + Platform.environment['RIVERSIDE_HTML_FIXTURE'] ?? 'not-present', + ); + if (!file.existsSync()) return; + final draft = SchoolPageAdapter().extract(file.readAsStringSync(), url); + expect(draft.courses.length, 11); + expect(draft.courses.map((c) => c.name).toSet().length, 7); + expect( + draft.courses + .singleWhere((c) => c.weekday == 2 && c.startPeriod == 9) + .endPeriod, + 11, + ); + expect( + draft.courses + .singleWhere((c) => c.weekday == 2 && c.startPeriod == 3) + .teacher + .split('、') + .length, + 2, + ); + }); +} diff --git a/mobile/test/widgets/complete_metadata_test.dart b/mobile/test/widgets/complete_metadata_test.dart new file mode 100644 index 0000000..b577e74 --- /dev/null +++ b/mobile/test/widgets/complete_metadata_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:timezone/data/latest.dart' as tzdata; +import 'package:hepan_everyday/features/timetable/week_grid.dart'; +import 'package:hepan_everyday/domain/schedule_engine.dart'; +import 'package:hepan_everyday/domain/app_settings.dart'; + +void main() { + setUpAll(tzdata.initializeTimeZones); + for (final scale in [1.0, 2.0]) { + testWidgets( + 'seven days and full teacher/room fit equal duration cards at scale $scale', + (tester) async { + tester.view.physicalSize = const Size(360, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final term = Semester(startDate: DateTime(2026, 9, 7), totalWeeks: 1); + Course c(String id, int day, String teacher, String room) => Course( + id: id, + name: '很长的课程名称应当可以省略但点击后显示完整课程名称$id', + teacher: teacher, + room: room, + weekday: day, + startPeriod: 3, + endPeriod: 4, + weeks: {1}, + source: CourseSource.file, + ); + final events = ScheduleEngine.occurrences([ + c('a', 1, '张晓玲、杨拥军', '品学楼B102'), + c('b', 7, '李老师', '立人楼教学实验中心B412'), + ], term); + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: MediaQueryData( + size: const Size(360, 800), + textScaler: TextScaler.linear(scale), + ), + child: Scaffold( + body: SingleChildScrollView( + child: WeekGrid( + events: events, + monday: term.firstMonday, + today: term.startDate, + settings: const AppSettings(), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getRect(find.text('周一')).left, greaterThanOrEqualTo(0)); + expect(tester.getRect(find.text('周日')).right, lessThanOrEqualTo(360)); + final first = tester.getRect( + find.bySemanticsLabel('${events.first.course.name},张晓玲、杨拥军,品学楼B102'), + ); + final last = tester.getRect( + find.bySemanticsLabel('${events.last.course.name},李老师,立人楼教学实验中心B412'), + ); + expect(first.height, last.height); + for (final text in ['张晓玲、杨拥军', '品学楼B102', '李老师', '立人楼教学实验中心B412']) { + final finder = find.text(text); + final widget = tester.widget(finder); + expect(widget.maxLines, isNull); + expect(widget.overflow, isNot(TextOverflow.ellipsis)); + final paragraph = tester.renderObject(finder); + expect(paragraph.didExceedMaxLines, isFalse); + final rect = tester.getRect(finder); + final card = text == '张晓玲、杨拥军' || text == '品学楼B102' ? first : last; + expect(rect.bottom, lessThanOrEqualTo(card.bottom)); + expect(rect.left, greaterThanOrEqualTo(card.left)); + expect(rect.right, lessThanOrEqualTo(card.right)); + } + expect(tester.takeException(), isNull); + await tester.ensureVisible(find.text(events.first.course.name)); + await tester.tap(find.text(events.first.course.name)); + await tester.pumpAndSettle(); + expect(find.text(events.first.course.name), findsNWidgets(2)); + }, + ); + } +} diff --git a/mobile/test/widgets/conflict_metadata_test.dart b/mobile/test/widgets/conflict_metadata_test.dart new file mode 100644 index 0000000..4f0d9b0 --- /dev/null +++ b/mobile/test/widgets/conflict_metadata_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:timezone/data/latest.dart' as tzdata; +import 'package:hepan_everyday/features/timetable/week_grid.dart'; +import 'package:hepan_everyday/domain/schedule_engine.dart'; +import 'package:hepan_everyday/domain/app_settings.dart'; + +void main() { + setUpAll(tzdata.initializeTimeZones); + testWidgets( + 'conflicts keep readable full-width metadata without covering courses', + (tester) async { + tester.view.physicalSize = const Size(320, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final term = Semester(startDate: DateTime(2026, 9, 7), totalWeeks: 1); + final courses = List.generate( + 5, + (i) => Course( + id: '$i', + name: '并行课程$i', + teacher: '示例教师$i', + room: '示例教学楼B10$i', + weekday: 1, + startPeriod: 1, + endPeriod: 2, + weeks: {1}, + source: CourseSource.file, + ), + ); + final events = ScheduleEngine.occurrences(courses, term); + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(2)), + child: Scaffold( + body: SingleChildScrollView( + child: WeekGrid( + events: events, + monday: term.firstMonday, + today: term.startDate, + settings: const AppSettings(), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('冲突列表'), findsOneWidget); + expect(find.text('1–2节'), findsNWidgets(5)); + final rects = []; + for (final c in courses) { + final card = tester.getRect( + find.bySemanticsLabel('${c.name},${c.teacher},${c.room}'), + ); + rects.add(card); + expect(card.width, greaterThan(30)); + for (final text in [c.teacher, c.room]) { + final finder = find.text(text); + final paragraph = tester.renderObject(finder); + for (final box in paragraph.getBoxesForSelection( + TextSelection(baseOffset: 0, extentOffset: text.length), + )) { + expect(box.right, lessThanOrEqualTo(paragraph.size.width + 0.01)); + } + expect(tester.getRect(finder).bottom, lessThanOrEqualTo(card.bottom)); + } + } + for (var i = 0; i < rects.length; i++) { + for (var j = i + 1; j < rects.length; j++) { + expect( + rects[i].intersect(rects[j]).height, + lessThanOrEqualTo(0.01), + reason: '${rects[i]} / ${rects[j]}', + ); + } + } + expect(tester.takeException(), isNull); + }, + ); + testWidgets( + 'a lunch-gap arrangement has the same length as another two-lesson card', + (tester) async { + final term = Semester(startDate: DateTime(2026, 9, 7), totalWeeks: 1); + Course c(String id, int day, int first) => Course( + id: id, + name: id, + teacher: '教师', + room: '教室', + weekday: day, + startPeriod: first, + endPeriod: first + 1, + weeks: {1}, + source: CourseSource.file, + ); + final events = ScheduleEngine.occurrences([ + c('A', 1, 3), + c('B', 2, 4), + ], term); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: WeekGrid( + events: events, + monday: term.firstMonday, + today: term.startDate, + settings: const AppSettings(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + tester.getRect(find.bySemanticsLabel('A,教师,教室')).height, + tester.getRect(find.bySemanticsLabel('B,教师,教室')).height, + ); + }, + ); +} diff --git a/mobile/test/widgets/glass_dock_test.dart b/mobile/test/widgets/glass_dock_test.dart new file mode 100644 index 0000000..4dda8bd --- /dev/null +++ b/mobile/test/widgets/glass_dock_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/widgets/glass_dock.dart'; +import 'package:hepan_everyday/app/hepan_app.dart'; +import 'package:timezone/data/latest.dart' as tzdata; + +import 'navigation_test.dart' show uiController; + +void main() { + setUpAll(tzdata.initializeTimeZones); + testWidgets( + 'glass cursor drags in both directions, taps work, body swipes do not navigate', + (tester) async { + final c = uiController(); + await c.initialize(); + await c.showDemo(); + await tester.pumpWidget(HepanApp(controller: c)); + await tester.pumpAndSettle(); + expect(find.text('Riverside Schedule'), findsOneWidget); + expect(tester.getRect(find.text('周日')).right, lessThanOrEqualTo(800)); + await tester.drag(find.text('周三'), const Offset(-250, 0)); + await tester.pumpAndSettle(); + expect(find.text('登录学校账户'), findsNothing); + await tester.drag( + find.byKey(const Key('dock-cursor')), + const Offset(100, 0), + ); + await tester.pumpAndSettle(); + expect(find.text('登录学校账户'), findsOneWidget); + await tester.drag(find.text('连接学校课表'), const Offset(250, 0)); + await tester.pumpAndSettle(); + expect(find.text('登录学校账户'), findsOneWidget); + await tester.drag( + find.byKey(const Key('dock-cursor')), + const Offset(-100, 0), + ); + await tester.pumpAndSettle(); + expect(find.text('示例课表'), findsOneWidget); + await tester.tap(find.bySemanticsLabel('我的')); + await tester.pumpAndSettle(); + expect(find.text('登录学校账户'), findsOneWidget); + c.dispose(); + }, + ); + testWidgets('cancelled cursor drag keeps selected tab', (tester) async { + int selected = 0; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) => Scaffold( + body: Center( + child: GlassDock( + selectedIndex: selected, + onSelected: (i) => setState(() => selected = i), + ), + ), + ), + ), + ), + ); + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const Key('dock-cursor'))), + ); + await gesture.moveBy(const Offset(90, 0)); + await tester.pump(); + await gesture.cancel(); + await tester.pumpAndSettle(); + expect(selected, 0); + }); +} diff --git a/mobile/test/widgets/navigation_test.dart b/mobile/test/widgets/navigation_test.dart index 0699ad8..54ada90 100644 --- a/mobile/test/widgets/navigation_test.dart +++ b/mobile/test/widgets/navigation_test.dart @@ -46,7 +46,8 @@ void main() { expect(find.text('示例课表'), findsOneWidget); expect(find.text('11'), findsOneWidget); await tester.ensureVisible(find.text('周一')); - await tester.drag(find.text('周一'), const Offset(-650, 0)); + expect(tester.getRect(find.text('周日')).right, lessThanOrEqualTo(360)); + await tester.drag(find.text('周一'), const Offset(-200, 0)); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); expect(find.text('周日'), findsOneWidget); diff --git a/mobile/test/widgets/timetable_test.dart b/mobile/test/widgets/timetable_test.dart index ebccbca..1f3d1d1 100644 --- a/mobile/test/widgets/timetable_test.dart +++ b/mobile/test/widgets/timetable_test.dart @@ -45,7 +45,7 @@ void main() { final a = tester.getRect(find.bySemanticsLabel('A,T,R')); final b = tester.getRect(find.bySemanticsLabel('B,T,R')); final cRect = tester.getRect(find.bySemanticsLabel('C,T,R')); - expect(a.right, lessThanOrEqualTo(b.left)); - expect(b.right, lessThanOrEqualTo(cRect.left)); + expect(a.overlaps(b), isFalse); + expect(b.overlaps(cRect), isFalse); }); } diff --git a/scripts/build-android.ps1 b/scripts/build-android.ps1 index 06bf35b..71762bb 100644 --- a/scripts/build-android.ps1 +++ b/scripts/build-android.ps1 @@ -10,6 +10,6 @@ try { $built=Join-Path (Get-Location) 'build/app/outputs/flutter-apk/app-debug.apk' if(!(Test-Path -LiteralPath $built)) { throw '本次构建未生成 APK' } New-Item -ItemType Directory -Force "$projectRoot/dist" | Out-Null - Copy-Item -LiteralPath $built -Destination "$projectRoot/dist/hepan-everyday-debug.apk" -Force - Get-Item -LiteralPath "$projectRoot/dist/hepan-everyday-debug.apk" | Select-Object FullName,Length,LastWriteTime + Copy-Item -LiteralPath $built -Destination "$projectRoot/dist/Riverside-Schedule-debug.apk" -Force + Get-Item -LiteralPath "$projectRoot/dist/Riverside-Schedule-debug.apk" | Select-Object FullName,Length,LastWriteTime } finally { Pop-Location }