diff --git a/.gitignore b/.gitignore index 73b4f50..28cd8e9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ dist/ artifacts/*.xml artifacts/emulator-*.txt artifacts/screenshots/probe-state*.png +artifacts/reference-video/ diff --git a/README.md b/README.md index 4f91329..250fefe 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,36 @@ -# Riverside Schedule +# RSchedule -电子科技大学研究生课表 APP,Flutter 实现,Android 0.2.1+3,Android 7.0 及以上。 +电子科技大学研究生课表 APP,Flutter 实现,Android 0.3.0+4,Android 7.0 及以上。 -课表周一至周日固定七列同屏,无横向滚动;课程条默认长度为125%,可在「我的 → 课表设置 → 课程条长度」调节为80%–200%,也可恢复默认;全表使用统一节次行高,同节数课程条等长。课程名最多两行,可省略,老师和教室完整换行。点击课程查看全称、老师、教室、节次、日期和上课周次。时间冲突用整列宽度的冲突列表展示,列表各项标注节次,避免窄列裁字。 +三个图标标签页:课表、考试、个人设置。透明蓝色液态玻璃风格悬浮 Dock 支持点击图标或拖动选中滑块切换;按压放大、拖动拉伸、松手弹簧回弹。页面切换采用短距离移动与淡入淡出,并保留每页滚动位置。页面本身不支持左右滑动切换。课表和考试页面向下滚动隐藏 Dock,向上滚动恢复;个人设置页面保持显示。系统“减少动画”开启时减少切页与回弹动画。 -底部只有日历和我的两个图标,透明液态玻璃风格悬浮 Dock,主题为蓝色,支持点击图标、拖动选中游标切换。页面本身不支持左右滑动切换。配色、课前提醒、手机日历和学期设置延续首版。 +课表周一至周日固定七列同屏,无横向滚动。同节数课程条等长;课程名可省略,老师和教室完整换行,点击查看全部信息。课程条默认长度125%,在「个人设置 → 课表设置 → 课程条长度」调节80%–200%。课前提醒、手机日历及学期设置继续保留。 -## 安装和导入 +## 安装与学校登录 -安装 `dist/Riverside-Schedule-debug.apk`。这是开发测试包;包名和原版签名保持一致,可覆盖安装并保留本地数据。 +安装 `dist/RSchedule-debug.apk`,可覆盖旧版并保留本地课表与设置。内部 Dart 包名和 Android 包名保留不变以兼容升级。 -在「我的 → 从文件导入」选择学校课表查询页保存的 `.html` / `.htm`,或版本1的课表 JSON。先检查预览,再确认导入。文件最多2MB,UTF-8编码。学校 HTML 中分散周次会合并为同一星期、节次、老师和教室的一条安排。 +在个人设置中打开学校账户。应用先访问 [研究生系统专用认证入口](https://yjsjy.uestc.edu.cn/pyxx/jzsso/login),学生在学校原网页填写账号、密码和验证码;应用没有自己的密码表单,不读取或注入密码。学校返回研究生系统后显示“正在查找课表,请稍候”,自动打开 [课表查询](https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1),识别完整表格后导入保存。 -HTML 不含学期开始日,因此仍须在「我的 → 课表设置」填写实际开始日和学期周数。默认课前15分钟提醒,可选5/10/15/30/60分钟。启用通知和手机日历时按页面提示授权。 +专用入口的原始302响应指向 HTTP 身份认证地址,携带研究生系统的 `service` 参数。应用将该身份认证地址转到 HTTPS 并完整保留参数。学校 HTTP 回调仅允许准确的研究生系统域名。读取或保存失败保留已有数据,可在学校认证页面重试。 -学校入口连接 [研究生课表查询页面](https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1),提供自绘认证入口及学校官方 WebView。已根据提供的实际HTML实现解析,实时个人登录和验证码流程仍需在本人手机上验证。文件导入不会冒充学校登录。详见 [学校接入状态](docs/school-integration-status.md)。 +真实账户认证及验证码仍需由本人操作验证;离线 HTML 与模拟回调测试不能代替真实账户联调。详见 [学校接入状态](docs/school-integration-status.md)。 -JSON样例:[schedule.schema-v1.json](examples/schedule.schema-v1.json)。示例模式独立,不覆盖本人课表,也不创建通知或日历事件。 +## 考试安排 + +考试页右上角可从学校更新,或导入学校考试查询页保存的 HTML。查询路径为 `/pyxx/pygl/kskcxx/index`,由用户提供的导出页面确认。考试数据独立保存,不覆盖课表,也不产生考试提醒。显示课程名称、考试时间、考场、座位号;有考核方式、考生编号或考场顺序号时一并显示。 + +提供的考试 HTML 含2026-2027秋学期8门课程,排考字段全部为空。当前显示“尚未安排”,不猜测考试时间和地点。未知或不完整表格拒绝导入;只有明确的无数据提示才接受空列表。原始附件与其中的学号、姓名不随源码或 APK 分发。 + +## 文件导入和设置 + +「个人设置 → 从文件导入」支持学校课表 `.html` / `.htm` 与版本1的 JSON,先预览再确认;考试文件从考试页导入。文件最多2MB,UTF-8编码;其中脚本不会执行。 + +HTML没有学期开始日,仍须在课表设置填写实际开始日和学期周数。默认课前15分钟提醒,可选5/10/15/30/60分钟。启用通知和手机日历时按提示授权。JSON样例:[schedule.schema-v1.json](examples/schedule.schema-v1.json)。示例课表不覆盖本人数据,不创建通知或日历事件。 ## 开发 -源码在 `mobile/`;内部 Dart 包名保留 `hepan_everyday`,Android包名保留 `com.example.hepan_everyday` 以兼容升级。本机工具位于 `.tools/`,不会提交进Git。 +源码在 `mobile/`;工具在 `.tools/`,不提交到 Git。Android 包名保留 `com.example.hepan_everyday`。 ```powershell $env:HEPAN_TOOLCHAIN_ROOT='C:/Users/david/Desktop/app/.tools' @@ -31,6 +41,4 @@ flutter test flutter analyze ``` -仓库根目录运行 `./scripts/build-android.ps1` 构建,成功后复制 APK 到 `dist/`。 - -验证记录:[0.2.1玻璃和长度设置](docs/blue-glass-and-course-length.md)、[0.2.0课表改版验证](docs/riverside-revision-validation.md)。Windows模拟器如遇图形驱动崩溃,可仅在测试启动时使用软件渲染并关闭Impeller;交付APK保留默认手机渲染配置。首版通知和日历原生验证保存在 [首版验证记录](docs/android-mvp-validation.md)。 +仓库根目录运行 `./scripts/build-android.ps1` 构建。验证记录:[0.3.0登录、考试和动画](docs/rschedule-0.3-validation.md)。Windows模拟器可仅在测试启动时使用软件渲染并关闭Impeller;交付APK保留手机默认渲染配置。历史验证:[0.2.1](docs/blue-glass-and-course-length.md)、[0.2.0](docs/riverside-revision-validation.md)、[首版](docs/android-mvp-validation.md)。 diff --git a/docs/rschedule-0.3-validation.md b/docs/rschedule-0.3-validation.md new file mode 100644 index 0000000..c8bd9b0 --- /dev/null +++ b/docs/rschedule-0.3-validation.md @@ -0,0 +1,34 @@ +# RSchedule 0.3.0+4 验证记录 + +日期:2026-10-13。沿用原包名和调试签名,支持Android 7.0及以上。 + +## 改动 + +1. 专用研究生SSO入口,移除自绘账号密码表单及凭证注入。保持学校网页验证,返回研究生系统后查找课表并自动保存。 +2. 修复学校专用入口的实际HTTP身份认证跳转:升级为HTTPS,保留研究生service参数。Android只为准确的研究生域名允许HTTP回调。 +3. 新增考试查询和HTML导入,单独存储考试列表。真实附件8门课程,所有排考字段为空,显示“尚未安排”。 +4. 应用名RSchedule;三个图标标签;切页短距离移动与淡入淡出,保留页面状态,没有页面横向切换手势。 +5. 透明蓝色玻璃Dock;选中滑块按压放大、拖动拉伸、弹簧回弹。课表和考试页向下滚动隐藏,向上恢复。 +6. 旧读取、旧网络错误、旧保存确认均校验当前会话。退出与新打开串行处理,清理失败在个人页显示并保留退出重试按钮。 + +## 自动验证 + +- 全套93项测试通过,包含两份实际学校HTML的本机可选验证;flutter analyze无问题。 +- 回归覆盖:service重定向、HTTP身份认证升级、验证码期间等待、认证返回后读取、过期网络错误、退出与重开竞争、原生退出失败恢复、失败提示与重试按钮、旧保存确认不清除新草稿、残缺考试表拒绝导入。 +- 七天同屏、老师和教室完整显示、等节数条长一致、长度设置保存、字体放大、Dock拖动与取消、页面横向手势不切页等原有检查继续通过。 + +## Android实测 + +- API36测试设备覆盖安装成功,显示RSchedule,versionName0.3.0/versionCode4。 +- 已从系统文件选择器导入真实考试HTML:8门课程、2026-2027秋,无排考信息;重启后8条记录仍在,原11条课程安排和125%长度设置保留。 +- 向下滚动后Dock图标从可访问界面中消失;向上滚动后三个图标恢复。点击可切换三页。 +- 专用入口实际302响应已用HTTP客户端核对。安卓WebView成功显示学校原身份认证页面、账号/密码栏和登录按钮,未填写个人凭据。 +- 截图位于dist/screenshots/rschedule-week.png、rschedule-exams.png、rschedule-school-login.png。 + +## 边界 + +真实本人账号、验证码、二次验证及登录后的真实自动导入未进行账号端到端验证;仅学校入口实测、真实HTML和会话回调测试已验证。请在手机上完成学校认证后检查导入结果。 + +玻璃视觉由Flutter背景模糊、透明渐变、双层高光边缘和弹簧形变实现,是参考视频的跨平台效果,不是苹果原生液态玻璃接口或物理折射实现。Windows模拟器测试时使用软件渲染并关闭Impeller,交付APK使用手机默认渲染。 + +原始HTML、视频、学号和姓名没有写入源码或安装包,个人数据仅用于本机附件验证。 diff --git a/docs/school-integration-status.md b/docs/school-integration-status.md index c917a06..7029df4 100644 --- a/docs/school-integration-status.md +++ b/docs/school-integration-status.md @@ -1,19 +1,27 @@ -# 学校接入状态 +# 学校接入状态 · RSchedule 0.3.0 -0.2.0已实现提供的研究生课表HTML结构解析,入口为 https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1,允许学校同路径下数字学期编号。保留HTTPS、域名、端口、登录表单检查;认证密码仍仅交给学校原有表单及加密逻辑,不保存到课表文件。 +专用入口:https://yjsjy.uestc.edu.cn/pyxx/jzsso/login。 +课表查询:https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1。 +考试查询:https://yjsjy.uestc.edu.cn/pyxx/pygl/kskcxx/index。 -## 已验证 +## 登录问题的原因与修复 -- 提供的HTML:7门课程,11条每周安排;分散周次合并;多位老师和教室完整保留;晚上9–11节保留三节。 -- 课表表格节次、星期列、课程记录、周次及单元格位置逐项校验。未知、部分损坏或空HTML拒绝导入,保留旧课表。 -- `.html`/`.htm`与JSON文件都先预览再确认。离线HTML只记为文件来源,不修改学校认证状态。 -- 正在读取的课表在导航或退出登录后失效,迟到的结果不能恢复登录状态或导入预览。 -- 原始附件中的学号、脚本和其他个人信息未放入源码或安装包。自动化测试使用合成课表;原始附件仅作为本机可选验证输入。 +旧版先打开课表或通用身份认证页面,缺少研究生系统专用 `service` 交接。2026-10-13直接核对学校入口HTTP响应,HTTPS专用入口302指向 `http://idas.uestc.edu.cn/authserver/login?service=http%3A%2F%2Fyjsjy.uestc.edu.cn%2Fpyxx%2Fjzsso%2Flogin`。安卓地址检查原先会拦截这一HTTP跳转。 -## 仍需本人验证 +现在从专用入口开始,把准确的HTTP身份认证入口升级为HTTPS,保留完整原始查询参数。移除自绘账号密码表单及JavaScript凭证注入,学生在学校WebView中完成认证和验证码。返回研究生系统且检测到其退出登录链接后自动查找目标页面;识别完整数据后保存。研究生域名的HTTP回调在Android网络配置中单独放行。其他HTTP身份认证地址不直接加载。 -实时统一身份认证、验证码、二次验证及学校登录重定向尚未用本人账户实测。解析器已连接WebView读取流程,但不能据离线HTML宣称实时登录已验证。 +导航、重新打开、退出会让旧读取失效。保存确认同时检查原页面版本和原草稿,旧结果不会删除新草稿或关闭新路由。退出操作与下一次打开串行交接,原生清理失败显示可恢复信息;迟到的网络错误必须匹配当前页面。 -当前解析器支持已提供表格的明确周次范围及周次列表;未知单双周文字或新的页面结构拒绝导入。没有课程的页面不会被猜测为清空指令。 +## 数据验证 -HTML没有提供学期开始日,导入不会推测或改写学期日期;需要在设置填写真实日期和周数。 +- 真实课表HTML:7门课程、11条每周安排;合并分散周次,保留全部老师、教室和晚上9–11节。 +- 真实考试HTML:2026-2027秋学期8门课程,考核方式、考生编号、考场顺序号、考场、座位号、考试时间全部为空。目前只能读取课程列表,不能得出具体排考信息。 +- 考试解析不保存页面里的学号、姓名。原始附件、视频和脚本不放进源码或APK;测试使用合成数据,真实附件仅作为本机可选验证输入。 +- 不完整或未知表格拒绝导入;考试空列表需要明确无数据占位,课表空页面不作为清空指令。 +- HTML文件导入不修改学校认证状态。课程和考试数据分开保存;升级兼容旧快照。 + +## 验证边界 + +没有使用本人账户填写密码、验证码或进行二次验证,因此不能宣称已完成真实账户端到端登录。已核对实际学校专用入口重定向,测试真实HTML识别以及会话回调、错误恢复和数据保存。 + +学校HTML不含学期开始日,需自行填写真实日期和周数;应用不会猜测或改写学期日期。后续学校表格发生变化时,未知结构会提示重试并保留已有数据。 diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index e330ede..b0b3708 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -6,7 +6,8 @@ diff --git a/mobile/android/app/src/main/res/xml/network_security_config.xml b/mobile/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..7741eed --- /dev/null +++ b/mobile/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + + + yjsjy.uestc.edu.cn + + diff --git a/mobile/lib/app/app_controller.dart b/mobile/lib/app/app_controller.dart index 5ed433f..dc0f75b 100644 --- a/mobile/lib/app/app_controller.dart +++ b/mobile/lib/app/app_controller.dart @@ -8,6 +8,7 @@ import '../services/notification_service.dart'; import '../services/calendar_service.dart'; import '../domain/schedule_engine.dart'; import '../data/demo_schedule.dart'; +import '../domain/exam_schedule.dart'; class AppController extends ChangeNotifier { AppController(this.store, this.notifications, this.calendar, this.clock); @@ -174,6 +175,13 @@ class AppController extends ChangeNotifier { } await _cleanupCalendar(); }); + Future applyExams(ExamSchedule value) => _run(() async { + final validated = ExamSchedule.fromJson(value.toJson()); + await _save(snapshot.copyWith(examSchedule: validated)); + }); + Future clearExams() => _run(() async { + await _save(snapshot.copyWith(clearExams: true)); + }); Future disableCalendar({required bool deleteEvents}) => _run(() async { await _save( snapshot.copyWith( diff --git a/mobile/lib/app/hepan_app.dart b/mobile/lib/app/hepan_app.dart index ba12602..617582b 100644 --- a/mobile/lib/app/hepan_app.dart +++ b/mobile/lib/app/hepan_app.dart @@ -7,6 +7,8 @@ import '../features/timetable/course_details.dart'; import 'app_controller.dart'; import 'app_theme.dart'; import '../widgets/glass_dock.dart'; +import '../widgets/tab_deck.dart'; +import '../features/exams/exams_page.dart'; import '../features/timetable/timetable_page.dart'; import '../features/profile/profile_page.dart'; import '../features/school/school_session.dart'; @@ -26,7 +28,7 @@ class HepanApp extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp( debugShowCheckedModeBanner: false, - title: 'Riverside Schedule', + title: 'RSchedule', theme: hepanTheme(), locale: const Locale('zh', 'CN'), supportedLocales: const [Locale('zh', 'CN')], @@ -57,6 +59,33 @@ class _Shell extends StatefulWidget { class _ShellState extends State<_Shell> with WidgetsBindingObserver { int _tab = 0; + bool _dockHidden = false; + double _scrollTravel = 0; + void _selectTab(int index) { + if (index == _tab) return; + setState(() { + _tab = index; + _dockHidden = false; + _scrollTravel = 0; + }); + } + + bool _scroll(ScrollNotification notification) { + if (_tab == 2 || notification.metrics.axis != Axis.vertical) return false; + if (notification is ScrollUpdateNotification && + notification.dragDetails != null) { + final delta = notification.scrollDelta ?? 0; + if (delta.sign != _scrollTravel.sign) _scrollTravel = 0; + _scrollTravel += delta; + final hide = notification.metrics.pixels > 0 && _scrollTravel > 24; + final reveal = notification.metrics.pixels <= 0 || _scrollTravel < -16; + if ((hide && !_dockHidden) || (reveal && _dockHidden)) { + setState(() => _dockHidden = hide); + } + } + return false; + } + @override void initState() { super.initState(); @@ -107,8 +136,13 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { } } - void _school() { + void _school({bool exams = false}) { if (widget.schoolBuilder != null) { + if (exams) { + widget.school?.openExams(); + } else { + widget.school?.openTimetable(); + } Navigator.push( context, MaterialPageRoute(builder: (_) => widget.schoolBuilder!()), @@ -135,7 +169,7 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { context: context, builder: (context) => AlertDialog( title: const Text('退出学校登录'), - content: const Text('已导入的课表是否保留在手机中?'), + content: const Text('已导入的课表和考试信息是否保留在手机中?'), actions: [ TextButton( onPressed: () => Navigator.pop(context, 0), @@ -143,11 +177,11 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { ), TextButton( onPressed: () => Navigator.pop(context, 1), - child: const Text('保留课表'), + child: const Text('保留课表和考试'), ), TextButton( onPressed: () => Navigator.pop(context, 2), - child: const Text('清除课表'), + child: const Text('清除课表和考试'), ), ], ), @@ -156,6 +190,7 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { await widget.school?.logout(); if (choice == 2) { await widget.controller.clearCourses(); + await widget.controller.clearExams(); } } @@ -232,19 +267,31 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { ), ), Expanded( - child: IndexedStack( - index: _tab, - children: [ - TimetablePage(controller: widget.controller), - ProfilePage( - controller: widget.controller, - onLogin: _school, - authStatus: widget.school?.statusLabel ?? '未登录', - onLogout: widget.school?.hasOpened == true - ? _logout - : null, - ), - ], + child: NotificationListener( + onNotification: _scroll, + child: TabDeck( + index: _tab, + children: [ + TimetablePage(controller: widget.controller), + ExamsPage( + controller: widget.controller, + onSync: () { + _school(exams: true); + }, + ), + ProfilePage( + controller: widget.controller, + onLogin: _school, + authStatus: widget.school?.statusLabel ?? '未登录', + logoutLabel: widget.school?.logoutFailed == true + ? '重试退出学校登录' + : '退出学校登录', + onLogout: widget.school?.canLogout == true + ? _logout + : null, + ), + ], + ), ), ), ], @@ -255,9 +302,29 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { right: 0, bottom: 16 + MediaQuery.paddingOf(context).bottom, child: Center( - child: GlassDock( - selectedIndex: _tab, - onSelected: (index) => setState(() => _tab = index), + child: IgnorePointer( + ignoring: _dockHidden, + child: ExcludeSemantics( + excluding: _dockHidden, + child: AnimatedSlide( + key: const Key('floating-dock'), + offset: _dockHidden + ? const Offset(0, 1.8) + : Offset.zero, + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 240), + curve: Curves.easeOutCubic, + child: AnimatedOpacity( + opacity: _dockHidden ? 0 : 1, + duration: const Duration(milliseconds: 180), + child: GlassDock( + selectedIndex: _tab, + onSelected: _selectTab, + ), + ), + ), + ), ), ), ), diff --git a/mobile/lib/domain/app_snapshot.dart b/mobile/lib/domain/app_snapshot.dart index bfd45fd..86d2bf6 100644 --- a/mobile/lib/domain/app_snapshot.dart +++ b/mobile/lib/domain/app_snapshot.dart @@ -1,6 +1,7 @@ import 'app_settings.dart'; import 'course.dart'; import 'semester.dart'; +import 'exam_schedule.dart'; class ImportMetadata { const ImportMetadata({ @@ -18,6 +19,7 @@ class AppSnapshot { this.settings = const AppSettings(), List courses = const [], this.importMetadata, + this.examSchedule, this.calendarCleanupPending = false, Map calendarEventIds = const {}, }) : courses = List.unmodifiable(courses), @@ -25,6 +27,7 @@ class AppSnapshot { final AppSettings settings; final List courses; final ImportMetadata? importMetadata; + final ExamSchedule? examSchedule; final Map calendarEventIds; final bool calendarCleanupPending; AppSnapshot copyWith({ @@ -33,6 +36,8 @@ class AppSnapshot { ImportMetadata? importMetadata, Map? calendarEventIds, bool clearMetadata = false, + ExamSchedule? examSchedule, + bool clearExams = false, bool? calendarCleanupPending, }) => AppSnapshot( settings: settings ?? this.settings, @@ -40,12 +45,14 @@ class AppSnapshot { importMetadata: clearMetadata ? null : importMetadata ?? this.importMetadata, + examSchedule: clearExams ? null : examSchedule ?? this.examSchedule, calendarEventIds: calendarEventIds ?? this.calendarEventIds, calendarCleanupPending: calendarCleanupPending ?? this.calendarCleanupPending, ); Map toJson() => { 'schemaVersion': 1, + 'examSchedule': examSchedule?.toJson(), 'courses': courses.map((c) => c.toJson()).toList(), 'settings': { 'semester': settings.semester == null @@ -109,6 +116,11 @@ class AppSnapshot { ); final metadata = json['importMetadata']; return AppSnapshot( + examSchedule: json['examSchedule'] == null + ? null + : ExamSchedule.fromJson( + Map.from(json['examSchedule'] as Map), + ), calendarCleanupPending: (json['calendarCleanupPending'] ?? false) as bool, courses: values, diff --git a/mobile/lib/domain/exam_schedule.dart b/mobile/lib/domain/exam_schedule.dart new file mode 100644 index 0000000..fb4e85d --- /dev/null +++ b/mobile/lib/domain/exam_schedule.dart @@ -0,0 +1,82 @@ +class ExamRecord { + const ExamRecord({ + required this.code, + required this.name, + this.assessment = '', + this.candidate = '', + this.sequence = '', + this.room = '', + this.seat = '', + this.time = '', + }); + final String code, name, assessment, candidate, sequence, room, seat, time; + Map toJson() => { + 'code': code, + 'name': name, + 'assessment': assessment, + 'candidate': candidate, + 'sequence': sequence, + 'room': room, + 'seat': seat, + 'time': time, + }; + factory ExamRecord.fromJson(Map json) { + String field(String key) { + final value = json[key]; + if (value is! String || value.length > 1000) { + throw const FormatException('考试信息无效'); + } + return value; + } + + final record = ExamRecord( + code: field('code'), + name: field('name'), + assessment: field('assessment'), + candidate: field('candidate'), + sequence: field('sequence'), + room: field('room'), + seat: field('seat'), + time: field('time'), + ); + if (record.code.isEmpty || record.name.isEmpty) { + throw const FormatException('考试课程无效'); + } + return record; + } +} + +class ExamSchedule { + ExamSchedule({ + required this.semesterLabel, + required this.termId, + required List records, + }) : records = List.unmodifiable(records); + final String semesterLabel, termId; + final List records; + Map toJson() => { + 'semesterLabel': semesterLabel, + 'termId': termId, + 'records': records.map((r) => r.toJson()).toList(), + }; + factory ExamSchedule.fromJson(Map json) { + final label = json['semesterLabel'], + id = json['termId'], + rows = json['records']; + if (label is! String || + label.trim().isEmpty || + id is! String || + !RegExp(r'^\d+$').hasMatch(id) || + rows is! List || + rows.length > 500) { + throw const FormatException('考试列表无效'); + } + final records = rows + .map((r) => ExamRecord.fromJson(Map.from(r as Map))) + .toList(); + if (records.map((r) => r.code).toSet().length != records.length) { + throw const FormatException('考试课程重复'); + } + return ExamSchedule(semesterLabel: label, termId: id, records: records); + } +} diff --git a/mobile/lib/features/exams/exams_page.dart b/mobile/lib/features/exams/exams_page.dart new file mode 100644 index 0000000..9f7528d --- /dev/null +++ b/mobile/lib/features/exams/exams_page.dart @@ -0,0 +1,202 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; + +import '../../app/app_controller.dart'; +import '../../app/app_theme.dart'; +import '../school/school_exam_adapter.dart'; + +class ExamsPage extends StatelessWidget { + const ExamsPage({super.key, required this.controller, required this.onSync}); + final AppController controller; + final VoidCallback onSync; + Future _import(BuildContext context) async { + try { + final file = await FilePicker.pickFile( + type: FileType.custom, + allowedExtensions: ['html', 'htm'], + ); + if (file == null) return; + final bytes = BytesBuilder(copy: false); + var length = 0; + await for (final chunk in file.readAsByteStream()) { + length += chunk.length; + if (length > 2 * 1024 * 1024) { + throw const FormatException('文件不能超过 2 MB'); + } + bytes.add(chunk); + } + final draft = SchoolExamAdapter.extract(utf8.decode(bytes.takeBytes())); + if (!context.mounted) return; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('导入考试信息'), + content: Text( + '${draft.semesterLabel} · ${draft.records.length} 门课程\n将更新本机考试列表,尚未公布的字段显示为“尚未安排”。', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('确认导入'), + ), + ], + ), + ); + if (confirmed == true) { + await controller.applyExams(draft); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(controller.lastError ?? '考试信息已导入')), + ); + } + } + } catch (error) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('导入未完成:$error'))); + } + } + } + + @override + Widget build(BuildContext context) { + final schedule = controller.snapshot.examSchedule; + return ListView( + key: const PageStorageKey('exams-scroll'), + padding: EdgeInsets.fromLTRB( + 22, + 10, + 22, + 140 + MediaQuery.paddingOf(context).bottom, + ), + children: [ + const Text( + 'RSchedule', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 18), + Row( + children: [ + const Expanded( + child: Text( + '考试安排', + style: TextStyle(fontSize: 25, fontWeight: FontWeight.w700), + ), + ), + IconButton( + tooltip: '从学校更新考试', + onPressed: onSync, + icon: const Icon(Icons.sync_rounded), + ), + IconButton( + tooltip: '导入考试 HTML', + onPressed: () => _import(context), + icon: const Icon(Icons.file_download_outlined), + ), + ], + ), + const SizedBox(height: 8), + if (schedule == null) ...[ + const SizedBox(height: 54), + const Icon( + Icons.assignment_outlined, + size: 64, + color: Color(0xFF9ABBE8), + ), + const SizedBox(height: 20), + const Text( + '等待学校公布考试安排', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 12), + const Text( + '连接研究生系统,或导入学校考试页面的 HTML。', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, height: 1.8), + ), + const SizedBox(height: 24), + Center( + child: FilledButton.icon( + onPressed: onSync, + icon: const Icon(Icons.school_outlined), + label: const Text('连接学校考试查询'), + ), + ), + ] else ...[ + Text( + '${schedule.semesterLabel} · ${schedule.records.length} 门课程', + style: TextStyle(fontSize: 13, color: ink.withValues(alpha: 0.55)), + ), + const SizedBox(height: 18), + if (schedule.records.isEmpty) const Text('该学期暂无考试课程'), + for (final exam in schedule.records) + Padding( + padding: const EdgeInsets.only(bottom: 14), + child: SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + exam.name, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 14), + _field(Icons.schedule_rounded, '考试时间', exam.time), + _field(Icons.location_on_outlined, '考场', exam.room), + _field(Icons.event_seat_outlined, '座位号', exam.seat), + if (exam.assessment.isNotEmpty) + _field( + Icons.assignment_outlined, + '考核方式', + exam.assessment, + ), + if (exam.candidate.isNotEmpty) + _field(Icons.badge_outlined, '考生编号', exam.candidate), + if (exam.sequence.isNotEmpty) + _field( + Icons.format_list_numbered, + '考场顺序号', + exam.sequence, + ), + ], + ), + ), + ), + const Text( + '排考信息以学校最新公布为准。', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 11, color: Color(0xFF8493A5)), + ), + ], + ], + ); + } + + Widget _field(IconData icon, String label, String value) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 17, color: const Color(0xFF007AFF)), + const SizedBox(width: 9), + Expanded( + child: Text( + '$label:${value.isEmpty ? '尚未安排' : value}', + style: const TextStyle(fontSize: 13, height: 1.5), + ), + ), + ], + ), + ); +} diff --git a/mobile/lib/features/profile/profile_page.dart b/mobile/lib/features/profile/profile_page.dart index b813634..74ed83b 100644 --- a/mobile/lib/features/profile/profile_page.dart +++ b/mobile/lib/features/profile/profile_page.dart @@ -17,11 +17,12 @@ class ProfilePage extends StatelessWidget { required this.onLogin, this.authStatus = '未登录', this.onLogout, + this.logoutLabel = '退出学校登录', }); final AppController controller; final VoidCallback onLogin; final VoidCallback? onLogout; - final String authStatus; + final String authStatus, logoutLabel; Future _import(BuildContext context) async { try { final file = await FilePicker.pickFile( @@ -67,7 +68,7 @@ class ProfilePage extends StatelessWidget { ), children: [ const Text( - 'Riverside Schedule', + 'RSchedule', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), ), const SizedBox(height: 16), @@ -114,7 +115,7 @@ class ProfilePage extends StatelessWidget { Center( child: TextButton( onPressed: onLogout, - child: const Text('退出学校登录'), + child: Text(logoutLabel), ), ), ], @@ -210,7 +211,7 @@ class ProfilePage extends StatelessWidget { child: Column( children: [ const Text( - 'Riverside Schedule', + 'RSchedule', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600), ), const SizedBox(height: 6), diff --git a/mobile/lib/features/school/school_auth_page.dart b/mobile/lib/features/school/school_auth_page.dart index 660a9e0..d9cb96e 100644 --- a/mobile/lib/features/school/school_auth_page.dart +++ b/mobile/lib/features/school/school_auth_page.dart @@ -2,8 +2,6 @@ import 'package:flutter/material.dart'; import 'package:webview_flutter/webview_flutter.dart'; import '../../app/app_controller.dart'; -import '../../app/app_theme.dart'; -import '../profile/import_preview.dart'; import 'school_session.dart'; class SchoolAuthPage extends StatefulWidget { @@ -19,202 +17,133 @@ class SchoolAuthPage extends StatefulWidget { } class _SchoolAuthPageState extends State { - final _username = TextEditingController(), - _password = TextEditingController(); - bool _showWeb = false, _previewing = false; + bool _saving = false; @override void initState() { super.initState(); widget.session.addListener(_changed); - if (!widget.session.hasOpened) { - widget.session.open(); - } + WidgetsBinding.instance.addPostFrameCallback((_) => _changed()); } void _changed() { if (!mounted) return; setState(() {}); - final draft = widget.session.pendingDraft; - if (draft != null && !_previewing) { - _previewing = true; - WidgetsBinding.instance.addPostFrameCallback((_) async { - if (!mounted) return; - await showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - builder: (_) => - ImportPreview(draft: draft, controller: widget.controller), + final s = widget.session; + final courses = s.pendingDraft, exams = s.pendingExams; + final generation = s.pageGeneration; + final route = ModalRoute.of(context); + if (_saving || (courses == null && exams == null)) return; + _saving = true; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) { + _saving = false; + return; + } + // The session draft must still belong to the same page after navigation. + if (generation != s.pageGeneration || + courses != s.pendingDraft || + exams != s.pendingExams) { + _saving = false; + _changed(); + return; + } + if (courses != null) { + await widget.controller.applyImport(courses, confirmedEmpty: false); + } + if (exams != null) await widget.controller.applyExams(exams); + final success = widget.controller.lastError == null; + final acknowledged = s.saved( + success: success, + generation: generation, + draft: courses, + exams: exams, + ); + _saving = false; + if (!mounted) return; + if (!acknowledged) { + _changed(); + return; + } + if (success && route?.isCurrent == true) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(exams == null ? '课表已导入;请核对学期开始日。' : '考试信息已更新'), + ), ); - widget.session.pendingDraft = null; - _previewing = false; - }); - } + } else { + setState(() {}); + } + }); } @override void dispose() { widget.session.removeListener(_changed); - _password.clear(); - _username.clear(); - _password.dispose(); - _username.dispose(); super.dispose(); } - Future _submit() async { - if (_username.text.trim().isEmpty || _password.text.isEmpty) { - ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar(content: Text('请输入学号和密码'))); - return; - } - try { - await widget.session.submitCredentials( - _username.text.trim(), - _password.text, - ); - } finally { - _password.clear(); - if (mounted) { - setState(() => _showWeb = true); - } - } - } - @override Widget build(BuildContext context) { final s = widget.session; return Scaffold( appBar: AppBar( - title: const Text( - '学校认证', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700), - ), + title: const Text('学校认证'), actions: [ IconButton( - tooltip: _showWeb ? '应用登录界面' : '学校页面', - onPressed: () => setState(() => _showWeb = !_showWeb), - icon: Icon( - _showWeb ? Icons.person_outline : Icons.language_rounded, - ), + tooltip: '重新认证', + onPressed: _saving ? null : s.openAuthentication, + icon: const Icon(Icons.refresh_rounded), ), IconButton( - tooltip: '打开学校课表', - onPressed: () { - setState(() => _showWeb = true); - s.openTimetable(); - }, - icon: const Icon(Icons.calendar_month_outlined), + tooltip: '重试读取', + onPressed: _saving + ? null + : (s.requestingExams ? s.openExams : s.openTimetable), + icon: const Icon(Icons.sync_rounded), ), ], ), body: Column( children: [ - if (s.busy) const LinearProgressIndicator(minHeight: 2), + if (s.busy || _saving) const LinearProgressIndicator(minHeight: 2), Container( width: double.infinity, - padding: const EdgeInsets.all(13), - color: river.withValues(alpha: 0.07), + padding: const EdgeInsets.all(12), + color: const Color(0xFFEFF6FF), child: Text( s.message, - style: const TextStyle(fontSize: 12, height: 1.5, color: ink), + style: const TextStyle(fontSize: 12, height: 1.5), ), ), Expanded( - child: _showWeb - ? WebViewWidget(controller: s.web) - : ListView( - padding: const EdgeInsets.all(24), - children: [ - const SizedBox(height: 20), - const Icon(Icons.school_outlined, size: 48, color: river), - const SizedBox(height: 20), - const Text( - '登录,连接你的每一天。', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 23, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 10), - Text( - '电子科技大学统一身份认证', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - color: ink.withValues(alpha: 0.5), - ), - ), - const SizedBox(height: 30), - SurfaceCard( + child: Stack( + children: [ + WebViewWidget(controller: s.web), + if (s.finding || _saving) + Positioned.fill( + child: ColoredBox( + color: const Color(0xFFF5F9FE), + child: Center( child: Column( + mainAxisSize: MainAxisSize.min, children: [ - TextField( - controller: _username, - textInputAction: TextInputAction.next, - autocorrect: false, - decoration: const InputDecoration( - labelText: '学号/工号', - prefixIcon: Icon(Icons.person_outline), - ), - ), - const SizedBox(height: 16), - TextField( - controller: _password, - obscureText: true, - enableSuggestions: false, - autocorrect: false, - decoration: const InputDecoration( - labelText: '学校密码', - prefixIcon: Icon(Icons.lock_outline), - ), - onSubmitted: (_) => _submit(), - ), + const CircularProgressIndicator(), const SizedBox(height: 20), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: !s.busy && s.formReady - ? _submit - : null, - child: Text(s.busy ? '正在准备学校认证…' : '登录并打开课表'), - ), - ), - const SizedBox(height: 10), - TextButton( - onPressed: () { - _password.clear(); - setState(() => _showWeb = true); - s.openAuthentication(); - }, - child: const Text('使用学校页面登录'), + Text( + _saving + ? '正在保存,请稍候' + : (s.requestingExams + ? '正在查找考试安排,请稍候' + : '正在查找课表,请稍候'), ), ], ), ), - const SizedBox(height: 20), - Text( - '密码仅交给学校处理,不在应用中保存。需要验证码或其他验证时,将由学校页面继续处理。', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12, - height: 1.7, - color: ink.withValues(alpha: 0.45), - ), - ), - const SizedBox(height: 14), - Text( - '学校课表识别待登录后联调;也可通过「我的」导入课表文件。', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 11, - height: 1.6, - color: ink.withValues(alpha: 0.45), - ), - ), - ], + ), ), + ], + ), ), ], ), diff --git a/mobile/lib/features/school/school_exam_adapter.dart b/mobile/lib/features/school/school_exam_adapter.dart new file mode 100644 index 0000000..19bf507 --- /dev/null +++ b/mobile/lib/features/school/school_exam_adapter.dart @@ -0,0 +1,91 @@ +import 'dart:convert'; + +import 'package:html/parser.dart' as html; + +import '../../domain/exam_schedule.dart'; + +class SchoolExamAdapter { + static ExamSchedule extract(String content) { + try { + if (utf8.encode(content).length > 2 * 1024 * 1024) { + throw const FormatException(); + } + final document = html.parse(content); + final selected = document.querySelector('#xqbh option[selected]'); + final term = selected?.attributes['value']; + final table = document.querySelector('#sample-table-1'); + const headers = [ + 'id', + '序号', + '学号', + '姓名', + '课程代码', + '课程名称', + '考核方式', + '考生编号', + '考场顺序号', + '考场', + '座位号', + '考试时间', + ]; + if (selected == null || + term == null || + table == null || + !RegExp(r'^\d+$').hasMatch(term)) { + throw const FormatException(); + } + final rows = table.querySelectorAll('tr'); + if (rows.isEmpty || rows.first.children.length != headers.length) { + throw const FormatException(); + } + for (var i = 0; i < headers.length; i++) { + if (rows.first.children[i].text.trim() != headers[i]) { + throw const FormatException(); + } + } + final records = []; + var explicitlyEmpty = false; + for (final row in rows.skip(1)) { + // DataTables can insert a single explicit no-data placeholder. + if (row.children.length == 1 && + row.children.first.attributes['colspan'] == '12' && + const [ + '没有数据', + '暂无数据', + '无数据', + 'No data available in table', + ].contains(row.text.trim())) { + explicitlyEmpty = true; + continue; + } + if (row.children.length != 12 || + row.querySelector('script,style') != null) { + throw const FormatException(); + } + final cells = row.children.map((c) => c.text.trim()).toList(); + records.add( + ExamRecord( + code: cells[4], + name: cells[5], + assessment: cells[6], + candidate: cells[7], + sequence: cells[8], + room: cells[9], + seat: cells[10], + time: cells[11], + ), + ); + } + if (records.isEmpty && !explicitlyEmpty) throw const FormatException(); + return ExamSchedule.fromJson( + ExamSchedule( + semesterLabel: selected.text.trim(), + termId: term, + records: records, + ).toJson(), + ); + } catch (_) { + throw const FormatException('未识别到完整的考试查询表,已有考试信息保持不变。'); + } + } +} diff --git a/mobile/lib/features/school/school_login_flow.dart b/mobile/lib/features/school/school_login_flow.dart new file mode 100644 index 0000000..c7f02ef --- /dev/null +++ b/mobile/lib/features/school/school_login_flow.dart @@ -0,0 +1,34 @@ +import 'school_navigation_policy.dart'; + +/// Only the authenticated graduate system may advance the service handshake. +class SchoolLoginFlow { + Uri? _target; + bool _requested = false; + void begin(Uri target) { + _target = target; + _requested = false; + } + + void reset() { + _target = null; + _requested = false; + } + + Uri? onLoaded(Uri uri, {required bool authenticated}) { + if (_target == null || !SchoolNavigationPolicy.isSchoolPage(uri)) { + return null; + } + if (uri.host == 'idas.uestc.edu.cn') { + _requested = false; + return null; + } + if (uri.path == _target!.path || + _requested || + !authenticated || + !uri.path.startsWith('/pyxx/')) { + return null; + } + _requested = true; + return _target; + } +} diff --git a/mobile/lib/features/school/school_navigation_policy.dart b/mobile/lib/features/school/school_navigation_policy.dart index 282592a..69a33bd 100644 --- a/mobile/lib/features/school/school_navigation_policy.dart +++ b/mobile/lib/features/school/school_navigation_policy.dart @@ -1,4 +1,21 @@ class SchoolNavigationPolicy { + static Uri? secureIdentityRedirect(Uri uri) { + if (uri.scheme != 'http' || + uri.port != 80 || + uri.userInfo.isNotEmpty || + uri.host != 'idas.uestc.edu.cn' || + uri.path != '/authserver/login') { + return null; + } + return Uri( + scheme: 'https', + host: uri.host, + path: uri.path, + query: uri.hasQuery ? uri.query : null, + fragment: uri.hasFragment ? uri.fragment : null, + ); + } + static bool canNavigate(Uri uri, {required bool isMainFrame}) => isMainFrame ? isSchoolPage(uri) : (uri.scheme == 'https' && uri.userInfo.isEmpty) || @@ -17,6 +34,18 @@ class SchoolNavigationPolicy { r'$', ).hasMatch(uri.path); static bool isSchoolPage(Uri uri) => + (_https(uri) && + const { + 'idas.uestc.edu.cn', + 'yjsjy.uestc.edu.cn', + }.contains(uri.host)) || + (uri.scheme == 'http' && + uri.port == 80 && + uri.userInfo.isEmpty && + uri.host == 'yjsjy.uestc.edu.cn' && + uri.path.startsWith('/pyxx/')); + static bool isExam(Uri uri) => _https(uri) && - const {'idas.uestc.edu.cn', 'yjsjy.uestc.edu.cn'}.contains(uri.host); + uri.host == 'yjsjy.uestc.edu.cn' && + uri.path == '/pyxx/pygl/kskcxx/index'; } diff --git a/mobile/lib/features/school/school_session.dart b/mobile/lib/features/school/school_session.dart index 4ca09df..0e7c052 100644 --- a/mobile/lib/features/school/school_session.dart +++ b/mobile/lib/features/school/school_session.dart @@ -1,11 +1,15 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:webview_flutter/webview_flutter.dart'; import '../../data/json_schedule_import.dart'; +import '../../domain/exam_schedule.dart'; import 'school_navigation_policy.dart'; import 'school_page_adapter.dart'; +import 'school_exam_adapter.dart'; +import 'school_login_flow.dart'; enum SchoolConnectionState { signedOut, @@ -21,15 +25,30 @@ class SchoolSession extends ChangeNotifier { ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setNavigationDelegate( NavigationDelegate( - onNavigationRequest: (request) { + onNavigationRequest: (request) async { final uri = Uri.tryParse(request.url); + final upgrade = uri == null || !request.isMainFrame + ? null + : SchoolNavigationPolicy.secureIdentityRedirect(uri); + if (upgrade != null) { + try { + await web.loadRequest(upgrade); + } catch (_) { + _failure('学校身份认证暂时无法打开,请重试。'); + } + return NavigationDecision.prevent; + } if (uri == null || !SchoolNavigationPolicy.canNavigate( uri, isMainFrame: request.isMainFrame, )) { - message = '学校跳转到了其他地址,已停止自动交接登录信息。'; - notifyListeners(); + if (kDebugMode && request.isMainFrame) { + debugPrint( + 'Blocked school navigation origin: ${uri?.scheme}://${uri?.host}', + ); + } + if (request.isMainFrame) _failure('学校跳转到了其他地址,请返回学校认证入口重试。'); return NavigationDecision.prevent; } return NavigationDecision.navigate; @@ -38,230 +57,293 @@ class SchoolSession extends ChangeNotifier { _pageGeneration++; _reader.invalidate(); pendingDraft = null; - if (!hasOpened) { - return; - } + pendingExams = null; + if (!hasOpened) return; _loading = true; - formReady = false; notifyListeners(); }, onPageFinished: _loaded, - onWebResourceError: (error) { - if (error.isForMainFrame == true) { - _loading = false; - message = '学校页面暂时未能加载,请检查网络或使用统一认证入口。'; - notifyListeners(); + onWebResourceError: (error) async { + if (error.isForMainFrame != true || error.url == null) return; + final generation = _pageGeneration; + final current = await web.currentUrl(); + if (hasOpened && + generation == _pageGeneration && + error.url == current) { + _failure('学校页面暂时未能加载,请检查网络后重试。'); } }, onHttpError: (error) async { + final generation = _pageGeneration; final current = Uri.tryParse(await web.currentUrl() ?? ''); - if (!hasOpened || - error.request == null || - error.request!.uri != current) { - return; + if (hasOpened && + generation == _pageGeneration && + error.request != null && + error.request!.uri == current) { + _failure('学校请求未完成,请稍后重试。'); } - _loading = false; - message = '学校页面请求未完成,可稍后重试。'; - notifyListeners(); }, ), ); } late final WebViewController web; final _reader = SchoolReader(SchoolPageAdapter()); + final _flow = SchoolLoginFlow(); + Timer? _timeout; SchoolConnectionState connectionState = SchoolConnectionState.signedOut; bool _loading = false, - _submitting = false, hasOpened = false, - formReady = false; - bool get busy => _loading || _submitting || _reader.busy; + finding = false, + requestingExams = false; + bool logoutFailed = false; + bool get canLogout => hasOpened || logoutFailed; + bool get busy => _loading || _reader.busy; int _pageGeneration = 0; + int get pageGeneration => _pageGeneration; + Future? _logoutFuture; + int _openSerial = 0; ImportDraft? pendingDraft; - String message = '请通过学校统一身份认证登录。'; - String get statusLabel => switch (connectionState) { - SchoolConnectionState.signedOut => '未登录', - SchoolConnectionState.authenticating => '请在学校页面完成认证', - SchoolConnectionState.connected => '学校认证已确认', - SchoolConnectionState.expired => '学校登录已过期', - SchoolConnectionState.unknown => '学校页面已打开 · 等待完整课表', - }; + ExamSchedule? pendingExams; + String message = '请在学校网页中完成登录与验证码。'; + String get statusLabel => logoutFailed + ? '学校退出未完成,请重试' + : switch (connectionState) { + SchoolConnectionState.signedOut => '未登录', + SchoolConnectionState.authenticating => '请在学校页面完成认证', + SchoolConnectionState.connected => '研究生系统已连接', + SchoolConnectionState.expired => '学校登录已过期', + SchoolConnectionState.unknown => '学校连接未确认', + }; + // HTTPS entry produces the same official HTTP service callback. Do not drop + // the graduate-service query by opening a generic identity login page. static final authUrl = Uri.parse( - 'https://idas.uestc.edu.cn/authserver/login', + 'https://yjsjy.uestc.edu.cn/pyxx/jzsso/login', ); static final timetableUrl = Uri.parse( 'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1', ); - Future open() => openTimetable(); - Future openTimetable() async { - hasOpened = true; - _loading = true; + static final examUrl = Uri.parse( + 'https://yjsjy.uestc.edu.cn/pyxx/pygl/kskcxx/index', + ); + Uri get _target => requestingExams ? examUrl : timetableUrl; + Future open() => _open(exams: false); + Future openTimetable() => _open(exams: false); + Future openExams() => _open(exams: true); + Future openAuthentication() => + _open(exams: requestingExams, forceAuth: true); + Future _open({required bool exams, bool forceAuth = false}) async { + final serial = ++_openSerial; + final closing = _logoutFuture; + if (closing != null) await closing; + if (serial != _openSerial) return; + _timeout?.cancel(); + _pageGeneration++; + _reader.invalidate(); pendingDraft = null; - message = '正在打开研究生课表页面…'; - notifyListeners(); - try { - await web.loadRequest(timetableUrl); - } catch (_) { - _loading = false; - message = '学校课表页面无法打开,请检查校园网络。'; - notifyListeners(); - } - } - - Future openAuthentication() async { + pendingExams = null; + requestingExams = exams; + logoutFailed = false; hasOpened = true; _loading = true; - message = '正在准备学校认证…'; + _flow.begin(_target); + final connected = + connectionState == SchoolConnectionState.connected && !forceAuth; + finding = connected; + message = connected ? _findingMessage : '请在学校网页中完成登录与验证码。'; + if (connected) _armTimeout(); notifyListeners(); try { - await web.loadRequest(authUrl); + await web.loadRequest(connected ? _target : authUrl); } catch (_) { - _loading = false; - message = '学校认证页面暂时不可用。'; - notifyListeners(); + if (serial == _openSerial) _failure('学校入口无法打开,请检查校园网络后重试。'); + } + } + + String get _findingMessage => requestingExams ? '正在查找考试安排,请稍候' : '正在查找课表,请稍候'; + void _armTimeout() { + _timeout?.cancel(); + _timeout = Timer( + const Duration(seconds: 45), + () => _failure('查找超时,请检查学校页面后重试。已有数据保持不变。'), + ); + } + + void _failure(String text) { + if (!hasOpened) return; + _pageGeneration++; + _reader.invalidate(); + _timeout?.cancel(); + finding = false; + _loading = false; + message = text; + notifyListeners(); + } + + Future _html() async { + final raw = await web.runJavaScriptReturningResult( + 'JSON.stringify(document.documentElement.outerHTML)', + ); + final value = raw is String ? raw : raw.toString(); + try { + final decoded = jsonDecode(value); + return decoded is String ? decoded : value; + } catch (_) { + return value; } } - static const _readyScript = """(() => { - if (location.origin !== 'https://idas.uestc.edu.cn' || location.pathname !== '/authserver/login') return false; - const f = document.querySelector('.login-main #pwdFromId'); - if (!f || f.method.toLowerCase() !== 'post') return false; - const action = new URL(f.action, location.href); - return action.origin === location.origin && action.pathname === '/authserver/login' - && !!f.querySelector('#username') && !!f.querySelector('#password') - && !!f.querySelector('#saltPassword') && !!f.querySelector('#pwdEncryptSalt') - && !!f.querySelector('#login_submit') && typeof window.startLogin === 'function' - && typeof window.encryptPassword === 'function' && typeof window.checkUserCaptcha === 'function'; - })()"""; bool _truth(Object value) => value == true || value.toString() == 'true'; Future _loaded(String url) async { - if (!hasOpened) { + if (!hasOpened) return; + final generation = _pageGeneration; + final uri = Uri.tryParse(url); + final current = await web.currentUrl(); + if (!hasOpened || + generation != _pageGeneration || + current != url || + uri == null || + !SchoolNavigationPolicy.isSchoolPage(uri)) { 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; + if (uri.host == 'idas.uestc.edu.cn') { + _timeout?.cancel(); + finding = false; + connectionState = SchoolConnectionState.authenticating; + _flow.onLoaded(uri, authenticated: false); + message = '请在学校网页中完成登录与验证码。'; + notifyListeners(); + return; + } + final targetPage = requestingExams + ? SchoolNavigationPolicy.isExam(uri) + : SchoolNavigationPolicy.isTimetable(uri); + if (targetPage) { + finding = true; + message = _findingMessage; + notifyListeners(); try { - final ready = _truth( - await web.runJavaScriptReturningResult(_readyScript), - ); - if (!hasOpened || generation != _pageGeneration) return; - formReady = ready; + if (requestingExams) { + final text = await _html(); + if (!hasOpened || generation != _pageGeneration) return; + final draft = SchoolExamAdapter.extract(text); + pendingExams = draft; + } else { + final draft = await readTimetable(); + if (!hasOpened || generation != _pageGeneration) return; + pendingDraft = draft; + } + _timeout?.cancel(); + connectionState = SchoolConnectionState.connected; + message = '读取成功,正在保存…'; } catch (_) { if (!hasOpened || generation != _pageGeneration) return; - formReady = false; - } - message = formReady ? '可以使用应用界面登录;验证码会在学校页面完成。' : '请使用学校原页面完成认证。'; - } else if (uri != null && SchoolNavigationPolicy.isTimetable(uri)) { - connectionState = SchoolConnectionState.unknown; - message = '已打开课表页面,正在核对课程结构。'; - try { - 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() - : '课表读取未完成,已有课表保持不变。'; - } - } else { - connectionState = SchoolConnectionState.unknown; - message = '可点击右上角日历打开学校课表。'; - } - notifyListeners(); - } - - Future submitCredentials(String username, String password) async { - if (busy) return; - _submitting = true; - message = '正在交接学校认证…'; - notifyListeners(); - try { - final url = Uri.tryParse(await web.currentUrl() ?? ''); - if (url == null || - !SchoolNavigationPolicy.canInjectCredentials(url) || - !_truth(await web.runJavaScriptReturningResult(_readyScript))) { - throw StateError('学校表单尚未准备好,请使用学校页面登录'); - } - final result = await web.runJavaScriptReturningResult('''(() => { - if (!($_readyScript)) return false; - const f = document.querySelector('.login-main #pwdFromId'); - const u = f.querySelector('#username'), p = f.querySelector('#password'); - u.value = ${jsonEncode(username)}; p.value = ${jsonEncode(password)}; - p.removeAttribute('readonly'); - u.dispatchEvent(new Event('input', {bubbles:true})); - p.dispatchEvent(new Event('input', {bubbles:true})); - window.checkUserCaptcha(); return true; - })()'''); - if (!_truth(result)) throw StateError('学校表单已变化,请使用学校页面登录'); - var settled = false; - for (var i = 0; i < 20; i++) { - if (_truth( - await web.runJavaScriptReturningResult( - '!!window.jQuery && window.jQuery.active === 0', - ), - )) { - settled = true; - break; - } - await Future.delayed(const Duration(milliseconds: 200)); - } - final current = Uri.tryParse(await web.currentUrl() ?? ''); - if (!settled || - current == null || - !SchoolNavigationPolicy.canInjectCredentials(current)) { - message = '请在学校页面继续完成验证。'; + _failure( + '没有读取到完整${requestingExams ? '考试信息' : '课表'},请检查学校页面后重试。已有数据保持不变。', + ); return; } - await web.runJavaScript('''(() => { - if (!($_readyScript)) return; - window.startLogin(document.querySelector('.login-main #pwdFromId #login_submit')); - })()'''); - message = '已交给学校处理;如有验证码,请在学校页面完成。'; - } catch (_) { - message = '登录交接未完成,请使用学校页面继续。'; - } finally { - _submitting = false; - notifyListeners(); + } else { + // A logout link is evidence of a graduate-system session, unlike simply + // returning to a callback URL or the identity administration dashboard. + bool authenticated = false; + try { + authenticated = _truth( + await web.runJavaScriptReturningResult("""(() => { + return Array.from(document.querySelectorAll('a[href]')).some(a => { + try { const u = new URL(a.href, location.href); return u.hostname === 'yjsjy.uestc.edu.cn' && u.pathname === '/pyxx/home/exit'; } catch (_) { return false; } + }); + })()"""), + ); + } catch (_) { + /* Keep the school page available for manual recovery. */ + } + if (!hasOpened || generation != _pageGeneration) return; + final next = _flow.onLoaded(uri, authenticated: authenticated); + if (next != null) { + finding = true; + _loading = true; + message = _findingMessage; + _armTimeout(); + notifyListeners(); + try { + await web.loadRequest(next); + } catch (_) { + _failure('课表查询页面暂时无法打开,请重试。'); + } + return; + } + finding = false; + message = '请在学校网页完成认证;进入研究生系统后会自动读取。'; } + notifyListeners(); } Future readTimetable() async { final uri = Uri.tryParse(await web.currentUrl() ?? ''); if (uri == null) throw UnsupportedSchoolLayout(); - return _reader.read(uri, () async { - final raw = await web.runJavaScriptReturningResult( - 'JSON.stringify(document.documentElement.outerHTML)', - ); - final value = raw is String ? raw : raw.toString(); - try { - final decoded = jsonDecode(value); - return decoded is String ? decoded : value; - } catch (_) { - return value; - } - }); + return _reader.read(uri, _html); } - Future logout() async { + bool saved({ + required bool success, + required int generation, + ImportDraft? draft, + ExamSchedule? exams, + }) { + if (generation != _pageGeneration || + draft != pendingDraft || + exams != pendingExams) { + return false; + } + pendingDraft = null; + pendingExams = null; + finding = false; + message = success + ? '${requestingExams ? '考试安排' : '课表'}已导入并在本机保存。' + : '保存未完成,请重试。'; + notifyListeners(); + return true; + } + + Future logout() { + if (_logoutFuture != null) return _logoutFuture!; + final future = _performLogout(); + _logoutFuture = future; + return future.whenComplete(() => _logoutFuture = null); + } + + Future _performLogout() async { + _openSerial++; _pageGeneration++; _reader.invalidate(); - hasOpened = false; - await WebViewCookieManager().clearCookies(); - await web.clearCache(); - await web.clearLocalStorage(); - await web.loadHtmlString(''); - _loading = false; + _flow.reset(); + _timeout?.cancel(); hasOpened = false; pendingDraft = null; - formReady = false; - connectionState = SchoolConnectionState.signedOut; - message = '已退出学校登录。'; + pendingExams = null; + finding = false; + var failed = false; + try { + await WebViewCookieManager().clearCookies(); + await web.clearCache(); + await web.clearLocalStorage(); + await web.loadHtmlString(''); + } catch (_) { + failed = true; + } + _loading = false; + logoutFailed = failed; + connectionState = failed + ? SchoolConnectionState.unknown + : SchoolConnectionState.signedOut; + message = failed ? '学校退出未完成,请重试;重新连接会再次经过学校认证。' : '已退出学校登录。'; notifyListeners(); } + + @override + void dispose() { + _timeout?.cancel(); + super.dispose(); + } } diff --git a/mobile/lib/features/timetable/timetable_page.dart b/mobile/lib/features/timetable/timetable_page.dart index 73e2558..1ee8d3c 100644 --- a/mobile/lib/features/timetable/timetable_page.dart +++ b/mobile/lib/features/timetable/timetable_page.dart @@ -32,6 +32,7 @@ class _TimetablePageState extends State { : term.firstMonday.add(Duration(days: (week - 1) * 7)); final sunday = monday.add(const Duration(days: 6)); return ListView( + key: const PageStorageKey('timetable-scroll'), padding: EdgeInsets.fromLTRB( 8, 10, @@ -40,7 +41,7 @@ class _TimetablePageState extends State { ), children: [ const Text( - 'Riverside Schedule', + 'RSchedule', style: TextStyle( fontSize: 22, fontWeight: FontWeight.w700, @@ -157,7 +158,7 @@ class _TimetablePageState extends State { ), const SizedBox(height: 12), Text( - '在「我的」设置学期并导入课程,\n或先看看示例课表。', + '在「个人设置」设置学期并导入课程,\n或先看看示例课表。', textAlign: TextAlign.center, style: TextStyle( fontSize: 13, diff --git a/mobile/lib/widgets/glass_dock.dart b/mobile/lib/widgets/glass_dock.dart index bc46cf4..30ffae1 100644 --- a/mobile/lib/widgets/glass_dock.dart +++ b/mobile/lib/widgets/glass_dock.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; class GlassDock extends StatefulWidget { const GlassDock({ @@ -15,16 +16,59 @@ class GlassDock extends StatefulWidget { State createState() => _GlassDockState(); } -class _GlassDockState extends State { - static const _width = 192.0, _travel = 88.0; +class _GlassDockState extends State with TickerProviderStateMixin { + static const _width = 280.0, _travel = 88.0; static const _blue = Color(0xFF007AFF); + late final AnimationController _motion = AnimationController.unbounded( + vsync: this, + value: widget.selectedIndex.toDouble(), + ); + late final AnimationController _press = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 140), + ); double? _drag; - double get _position => _drag ?? widget.selectedIndex.toDouble(); - void _select(int index) { + double _dragSpeed = 0; + double get _position => _drag ?? _motion.value.clamp(-0.12, 2.12); + void _spring(int index, [double velocity = 0]) { + if (MediaQuery.disableAnimationsOf(context)) { + _motion.value = index.toDouble(); + return; + } + _motion.animateWith( + SpringSimulation( + const SpringDescription(mass: 1, stiffness: 380, damping: 23), + _position, + index.toDouble(), + velocity.clamp(-8, 8), + ), + ); + } + + void _select(int index, [double velocity = 0]) { + final position = _position; setState(() => _drag = null); + _motion.value = position; + _spring(index, velocity); + _press.reverse(); widget.onSelected(index); } + @override + void didUpdateWidget(GlassDock oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selectedIndex != widget.selectedIndex && _drag == null) { + _spring(widget.selectedIndex); + } + } + + @override + void dispose() { + _motion.dispose(); + _press.dispose(); + super.dispose(); + } + Widget _glass({required Widget child, bool lens = false}) => ClipRRect( borderRadius: BorderRadius.circular(lens ? 36 : 40), child: BackdropFilter( @@ -61,50 +105,60 @@ class _GlassDockState extends State { ); @override - Widget build(BuildContext context) => SizedBox( - width: _width, - height: 70, - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(40), - boxShadow: [ - BoxShadow( - color: _blue.withValues(alpha: 0.035), - blurRadius: 12, - offset: const Offset(0, 5), + Widget build(BuildContext context) => AnimatedBuilder( + animation: Listenable.merge([_motion, _press]), + builder: (context, _) { + final press = Curves.easeOut.transform(_press.value); + final speed = (_drag != null ? _dragSpeed : _motion.velocity).abs(); + final stretch = (speed / 16).clamp(0.0, 0.14) + press * 0.04; + final lensWidth = 88 * (1 + stretch); + final lensHeight = 56 + press * 5 - stretch * 9; + return SizedBox( + width: _width, + height: 70, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(40), + boxShadow: [ + BoxShadow( + color: _blue.withValues(alpha: 0.035), + blurRadius: 12, + offset: const Offset(0, 5), + ), + ], ), - ], - ), - child: _glass( - child: Stack( - children: [ - 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: _glass(lens: true, child: const SizedBox.expand()), - ), + child: _glass( + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 8 + _position * _travel - (lensWidth - 88) / 2, + top: (70 - lensHeight) / 2, + width: lensWidth, + height: lensHeight, + child: IgnorePointer( + child: _glass(lens: true, child: const SizedBox.expand()), + ), + ), + Positioned( + left: 8, + right: 8, + top: 7, + bottom: 7, + child: Row( + children: [ + _item(0, Icons.calendar_month_rounded, '课表'), + _item(1, Icons.assignment_outlined, '考试'), + _item(2, Icons.person_rounded, '个人设置'), + ], + ), + ), + ], ), - Positioned( - left: 8, - right: 8, - top: 7, - bottom: 7, - child: Row( - children: [ - _item(0, Icons.calendar_month_rounded, '课表'), - _item(1, Icons.person_rounded, '我的'), - ], - ), - ), - ], + ), ), - ), - ), + ); + }, ); Widget _item(int index, IconData icon, String label) => Expanded( child: Semantics( @@ -116,31 +170,59 @@ class _GlassDockState extends State { child: GestureDetector( key: index == widget.selectedIndex ? const Key('dock-cursor') : null, behavior: HitTestBehavior.opaque, + onTapDown: (_) => _press.forward(), + onTapCancel: () { + if (_drag == null) _press.reverse(); + }, onTap: () => _select(index), onHorizontalDragStart: index == widget.selectedIndex - ? (_) => setState(() => _drag = widget.selectedIndex.toDouble()) + ? (_) { + final start = _position.clamp(0.0, 2.0); + _motion.stop(); + _press.forward(); + setState(() { + _drag = start; + _dragSpeed = 0; + }); + } : null, onHorizontalDragUpdate: index == widget.selectedIndex - ? (details) => setState( - () => _drag = (_position + details.delta.dx / _travel).clamp( - 0, - 1, - ), - ) + ? (details) => setState(() { + _dragSpeed = details.delta.dx / _travel * 60; + _drag = (_position + details.delta.dx / _travel).clamp( + 0.0, + 2.0, + ); + }) : null, onHorizontalDragEnd: index == widget.selectedIndex - ? (_) => _select(_position >= 0.5 ? 1 : 0) + ? (details) => _select( + _position.round().clamp(0, 2), + details.velocity.pixelsPerSecond.dx / _travel, + ) : null, onHorizontalDragCancel: index == widget.selectedIndex - ? () => setState(() => _drag = null) + ? () { + final position = _position; + setState(() => _drag = null); + _motion.value = position; + _spring(widget.selectedIndex); + _press.reverse(); + } : null, child: Center( - child: Icon( - icon, - size: 27, - color: index == (_position >= 0.5 ? 1 : 0) - ? _blue - : _blue.withValues(alpha: 0.48), + child: AnimatedScale( + scale: index == _position.round().clamp(0, 2) + ? 1 + _press.value * 0.08 + : 1, + duration: const Duration(milliseconds: 130), + child: Icon( + icon, + size: 27, + color: index == _position.round().clamp(0, 2) + ? _blue + : _blue.withValues(alpha: 0.48), + ), ), ), ), diff --git a/mobile/lib/widgets/tab_deck.dart b/mobile/lib/widgets/tab_deck.dart new file mode 100644 index 0000000..20ff3b8 --- /dev/null +++ b/mobile/lib/widgets/tab_deck.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +/// Retains all tab states and scroll positions, with no page-swipe recognizer. +class TabDeck extends StatefulWidget { + const TabDeck({super.key, required this.index, required this.children}); + final int index; + final List children; + @override + State createState() => _TabDeckState(); +} + +class _TabDeckState extends State with SingleTickerProviderStateMixin { + late final AnimationController _motion = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 330), + value: 1, + ); + int? _outgoing; + int _direction = 1; + @override + void didUpdateWidget(TabDeck oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.index != widget.index) { + _outgoing = oldWidget.index; + _direction = widget.index > oldWidget.index ? 1 : -1; + if (MediaQuery.disableAnimationsOf(context)) { + _motion.value = 1; + } else { + _motion.forward(from: 0); + } + } + } + + @override + void dispose() { + _motion.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: _motion, + builder: (context, _) { + final t = Curves.easeOutCubic.transform(_motion.value); + return Stack( + fit: StackFit.expand, + children: [ + for (var i = 0; i < widget.children.length; i++) + Offstage( + offstage: + i != widget.index && (i != _outgoing || _motion.isCompleted), + child: TickerMode( + enabled: i == widget.index, + child: IgnorePointer( + ignoring: i != widget.index, + child: ExcludeSemantics( + excluding: i != widget.index, + child: Opacity( + opacity: i == widget.index + ? t + : (i == _outgoing ? 1 - t : 0), + child: Transform.translate( + offset: Offset( + (i == widget.index ? 1 - t : -t) * 22 * _direction, + 0, + ), + child: widget.children[i], + ), + ), + ), + ), + ), + ), + ], + ); + }, + ); +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index fa6206e..d032183 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -626,7 +626,7 @@ packages: source: hosted version: "4.14.1" webview_flutter_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: webview_flutter_platform_interface sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index caf0259..635d8ef 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,7 +1,7 @@ name: hepan_everyday -description: Riverside Schedule 研究生课表 +description: RSchedule 研究生课表 publish_to: none -version: 0.2.1+3 +version: 0.3.0+4 environment: sdk: ^3.13.5 dependencies: @@ -16,6 +16,7 @@ dependencies: path_provider: 2.1.6 file_picker: 13.1.0 dev_dependencies: + webview_flutter_platform_interface: 2.15.1 flutter_test: sdk: flutter integration_test: diff --git a/mobile/test/school/exam_adapter_test.dart b/mobile/test/school/exam_adapter_test.dart new file mode 100644 index 0000000..eb3a82a --- /dev/null +++ b/mobile/test/school/exam_adapter_test.dart @@ -0,0 +1,78 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/features/school/school_exam_adapter.dart'; +import 'package:hepan_everyday/domain/app_snapshot.dart'; + +String examHtml({String time = '', String room = '', String seat = ''}) => + ''' + + +${['id', '序号', '学号', '姓名', '课程代码', '课程名称', '考核方式', '考生编号', '考场顺序号', '考场', '座位号', '考试时间'].map((s) => '').join()} + +${['1', '1', 'private-id', 'private-name', 'MATH', '矩阵理论', '笔试', '', '', room, seat, time].map((s) => '').join()} +
$s
$s
'''; + +void main() { + test( + 'unscheduled courses and supplied arrangement fields survive saving', + () { + final draft = SchoolExamAdapter.extract(examHtml()); + expect(draft.records.single.name, '矩阵理论'); + expect(draft.records.single.time, ''); + final arranged = SchoolExamAdapter.extract( + examHtml(time: '2026-12-01 09:00–11:00', room: '品学楼A101', seat: '16'), + ); + final saved = AppSnapshot.fromJson( + AppSnapshot(examSchedule: arranged).toJson(), + ); + expect(saved.examSchedule!.records.single.room, '品学楼A101'); + expect(saved.examSchedule!.records.single.seat, '16'); + expect(saved.examSchedule!.records.single.time, '2026-12-01 09:00–11:00'); + final text = saved.toJson().toString(); + expect(text, isNot(contains('private-id'))); + expect(text, isNot(contains('private-name'))); + }, + ); + test('partial exam pages cannot replace previously imported records', () { + final headerOnly = examHtml().replaceAll( + RegExp(r'.*?', dotAll: true), + '', + ); + expect(() => SchoolExamAdapter.extract(headerOnly), throwsFormatException); + expect( + () => + SchoolExamAdapter.extract(examHtml().replaceFirst('16', '')), + returnsNormally, + ); + expect( + () => SchoolExamAdapter.extract( + examHtml().replaceFirst('MATH', ''), + ), + throwsFormatException, + ); + expect( + () => SchoolExamAdapter.extract('登录'), + throwsFormatException, + ); + }); + final actual = Platform.environment['RSCHEDULE_EXAM_FIXTURE']; + if (actual != null) { + test( + 'provided exam export has eight courses with no arrangements', + () async { + final draft = SchoolExamAdapter.extract( + await File(actual).readAsString(), + ); + expect(draft.records.length, 8); + expect(draft.semesterLabel, '2026-2027秋'); + expect( + draft.records.every( + (e) => e.time.isEmpty && e.room.isEmpty && e.seat.isEmpty, + ), + isTrue, + ); + }, + ); + } +} diff --git a/mobile/test/school/graduate_entry_test.dart b/mobile/test/school/graduate_entry_test.dart new file mode 100644 index 0000000..da4fa4e --- /dev/null +++ b/mobile/test/school/graduate_entry_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/features/school/school_session.dart'; +import 'package:hepan_everyday/features/school/school_navigation_policy.dart'; + +void main() { + test( + 'login starts at graduate-specific service and accepts its HTTP callback', + () { + expect(SchoolSession.authUrl.path, '/pyxx/jzsso/login'); + expect(SchoolSession.authUrl.host, 'yjsjy.uestc.edu.cn'); + expect( + SchoolNavigationPolicy.isSchoolPage( + Uri.parse( + 'http://yjsjy.uestc.edu.cn/pyxx/jzsso/login?ticket=ST-example', + ), + ), + isTrue, + ); + expect( + SchoolNavigationPolicy.isSchoolPage( + Uri.parse('http://idas.uestc.edu.cn/authserver/login'), + ), + isFalse, + ); + expect( + SchoolNavigationPolicy.isSchoolPage( + Uri.parse('http://yjsjy.uestc.edu.cn.evil.test/pyxx/jzsso/login'), + ), + isFalse, + ); + }, + ); +} diff --git a/mobile/test/school/login_flow_test.dart b/mobile/test/school/login_flow_test.dart new file mode 100644 index 0000000..d546418 --- /dev/null +++ b/mobile/test/school/login_flow_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/features/school/school_login_flow.dart'; + +void main() { + test('wait for student authentication then request timetable once', () { + final flow = SchoolLoginFlow(); + final target = Uri.parse( + 'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1', + ); + flow.begin(target); + expect( + flow.onLoaded( + Uri.parse( + 'https://idas.uestc.edu.cn/authserver/login?service=graduate', + ), + authenticated: false, + ), + isNull, + ); + expect( + flow.onLoaded( + Uri.parse('http://yjsjy.uestc.edu.cn/pyxx/jzsso/login?ticket=ST-test'), + authenticated: false, + ), + isNull, + ); + expect( + flow.onLoaded( + Uri.parse('http://yjsjy.uestc.edu.cn/pyxx/home/index'), + authenticated: true, + ), + target, + ); + expect( + flow.onLoaded( + Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/home/index'), + authenticated: true, + ), + isNull, + ); + expect(flow.onLoaded(target, authenticated: true), isNull); + flow.reset(); + expect( + flow.onLoaded( + Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/home/index'), + authenticated: true, + ), + isNull, + ); + }); + test( + 'reauthentication and untrusted redirects cannot jump into timetable', + () { + final flow = SchoolLoginFlow(); + final target = Uri.parse( + 'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1', + ); + flow.begin(target); + expect( + flow.onLoaded( + Uri.parse('https://evil.test/pyxx/home/index'), + authenticated: true, + ), + isNull, + ); + expect( + flow.onLoaded( + Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/home/index'), + authenticated: false, + ), + isNull, + ); + expect( + flow.onLoaded( + Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/home/index'), + authenticated: true, + ), + target, + ); + flow.onLoaded( + Uri.parse('https://idas.uestc.edu.cn/authserver/login'), + authenticated: false, + ); + expect( + flow.onLoaded( + Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/home/index'), + authenticated: true, + ), + target, + ); + }, + ); +} diff --git a/mobile/test/school/school_session_flow_test.dart b/mobile/test/school/school_session_flow_test.dart new file mode 100644 index 0000000..18360c9 --- /dev/null +++ b/mobile/test/school/school_session_flow_test.dart @@ -0,0 +1,140 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; +import 'package:hepan_everyday/features/school/school_session.dart'; +import 'package:hepan_everyday/features/school/school_exam_adapter.dart'; + +import '../support/fake_school_web.dart'; +import 'exam_adapter_test.dart' show examHtml; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late FakeSchoolPlatform platform; + late SchoolSession session; + setUp(() { + platform = FakeSchoolPlatform(); + WebViewPlatform.instance = platform; + session = SchoolSession(); + }); + tearDown(() => session.dispose()); + void navigate(String url) { + platform.web.url = url; + platform.delegate.started(url); + platform.delegate.finished(url); + } + + test('actual session follows service login then reads exam HTML', () async { + await session.openExams(); + expect(platform.web.loads.single, SchoolSession.authUrl.toString()); + navigate('https://idas.uestc.edu.cn/authserver/login?service=graduate'); + await flushSchoolEvents(); + expect(session.connectionState, SchoolConnectionState.authenticating); + expect(session.finding, isFalse); + platform.web.javascript = (script) async => + script.contains('outerHTML') ? jsonEncode(examHtml()) : true; + navigate('http://yjsjy.uestc.edu.cn/pyxx/home/index'); + await flushSchoolEvents(); + expect(platform.web.loads.last, SchoolSession.examUrl.toString()); + expect(session.finding, isTrue); + navigate(SchoolSession.examUrl.toString()); + await flushSchoolEvents(); + expect(session.pendingExams!.records.single.name, '矩阵理论'); + expect(session.connectionState, SchoolConnectionState.connected); + }); + test( + 'obsolete main-frame error cannot interrupt a newer exam read', + () async { + await session.openExams(); + platform.web.javascript = (_) async => jsonEncode(examHtml()); + navigate(SchoolSession.examUrl.toString()); + await flushSchoolEvents(); + platform.delegate.resourceError( + const WebResourceError( + errorCode: -2, + description: 'old request', + isForMainFrame: true, + url: 'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1', + ), + ); + await flushSchoolEvents(); + expect(session.finding, isTrue); + expect(session.message, '读取成功,正在保存…'); + }, + ); + test( + 'open waits for logout so old clearing cannot blank the new login', + () async { + await session.open(); + platform.cookies.clearing = Completer(); + final closing = session.logout(); + await flushSchoolEvents(); + final reopening = session.openExams(); + await flushSchoolEvents(); + platform.cookies.clearing!.complete(true); + await closing; + await reopening; + expect(session.hasOpened, isTrue); + expect(platform.web.loads.last, SchoolSession.authUrl.toString()); + expect(session.requestingExams, isTrue); + }, + ); + test( + 'acknowledging an old saved draft preserves a newer pending import', + () async { + await session.openExams(); + final old = SchoolExamAdapter.extract(examHtml()); + session.pendingExams = old; + final generation = session.pageGeneration; + await session.openExams(); + final newer = SchoolExamAdapter.extract(examHtml(room: '品学楼A101')); + session.pendingExams = newer; + expect( + session.saved(success: true, generation: generation, exams: old), + isFalse, + ); + expect(session.pendingExams, same(newer)); + expect( + session.saved( + success: true, + generation: session.pageGeneration, + exams: newer, + ), + isTrue, + ); + expect(session.pendingExams, isNull); + }, + ); + test( + 'native logout failure is reported and subsequent login can recover', + () async { + await session.open(); + session.connectionState = SchoolConnectionState.connected; + platform.cookies.clearing = Completer(); + final closing = session.logout(); + final result = closing.then((_) => true, onError: (_) => false); + platform.cookies.clearing!.completeError( + StateError('cookie clearing failed'), + ); + expect(await result, isTrue); + expect(session.connectionState, isNot(SchoolConnectionState.connected)); + expect(session.message, contains('退出未完成')); + await session.openExams(); + expect(platform.web.loads.last, SchoolSession.authUrl.toString()); + }, + ); + test('university HTTP identity redirect upgrades to HTTPS with service preserved', () async { + await session.open(); + const redirect = + 'http://idas.uestc.edu.cn/authserver/login?service=http%3A%2F%2Fyjsjy.uestc.edu.cn%2Fpyxx%2Fjzsso%2Flogin'; + final decision = await platform.delegate.navigation( + const NavigationRequest(url: redirect, isMainFrame: true), + ); + expect(decision, NavigationDecision.prevent); + final opened = Uri.parse(platform.web.loads.last); + expect(opened.host, 'idas.uestc.edu.cn'); + expect(opened.scheme, 'https'); + expect(opened.query, Uri.parse(redirect).query); + }); +} diff --git a/mobile/test/support/fake_school_web.dart b/mobile/test/support/fake_school_web.dart new file mode 100644 index 0000000..47e376a --- /dev/null +++ b/mobile/test/support/fake_school_web.dart @@ -0,0 +1,102 @@ +import 'dart:async'; + +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +class FakeSchoolPlatform extends WebViewPlatform { + late FakeSchoolWeb web; + late FakeSchoolDelegate delegate; + final cookies = FakeSchoolCookies(); + @override + PlatformWebViewController createPlatformWebViewController( + PlatformWebViewControllerCreationParams params, + ) => web = FakeSchoolWeb(params); + @override + PlatformNavigationDelegate createPlatformNavigationDelegate( + PlatformNavigationDelegateCreationParams params, + ) => delegate = FakeSchoolDelegate(params); + @override + PlatformWebViewCookieManager createPlatformCookieManager( + PlatformWebViewCookieManagerCreationParams params, + ) => cookies; +} + +class FakeSchoolWeb extends PlatformWebViewController { + FakeSchoolWeb(super.params) : super.implementation(); + final loads = []; + String? url; + Future Function(String)? javascript; + @override + Future setJavaScriptMode(JavaScriptMode mode) async {} + @override + Future setPlatformNavigationDelegate( + PlatformNavigationDelegate handler, + ) async {} + @override + Future loadRequest(LoadRequestParams params) async { + url = params.uri.toString(); + loads.add(url!); + } + + @override + Future currentUrl() async => url; + @override + Future runJavaScriptReturningResult(String script) => + javascript?.call(script) ?? Future.value(false); + @override + Future clearCache() async {} + @override + Future clearLocalStorage() async {} + @override + Future loadHtmlString(String html, {String? baseUrl}) async { + url = 'about:blank'; + loads.add('about:blank'); + } +} + +class FakeSchoolDelegate extends PlatformNavigationDelegate { + FakeSchoolDelegate(super.params) : super.implementation(); + late PageEventCallback started, finished; + late WebResourceErrorCallback resourceError; + late NavigationRequestCallback navigation; + @override + Future setOnNavigationRequest( + NavigationRequestCallback callback, + ) async { + navigation = callback; + } + + @override + Future setOnPageStarted(PageEventCallback callback) async { + started = callback; + } + + @override + Future setOnPageFinished(PageEventCallback callback) async { + finished = callback; + } + + @override + Future setOnWebResourceError(WebResourceErrorCallback callback) async { + resourceError = callback; + } + + @override + Future setOnHttpError(HttpResponseErrorCallback callback) async {} +} + +class FakeSchoolCookies extends PlatformWebViewCookieManager { + FakeSchoolCookies() + : super.implementation(const PlatformWebViewCookieManagerCreationParams()); + Completer? clearing; + bool fail = false; + @override + Future clearCookies() => fail + ? Future.error(StateError('native cookies')) + : clearing?.future ?? Future.value(true); +} + +Future flushSchoolEvents() async { + for (var i = 0; i < 12; i++) { + await Future.delayed(Duration.zero); + } +} diff --git a/mobile/test/widgets/glass_dock_test.dart b/mobile/test/widgets/glass_dock_test.dart index 4dda8bd..5bf3197 100644 --- a/mobile/test/widgets/glass_dock_test.dart +++ b/mobile/test/widgets/glass_dock_test.dart @@ -16,14 +16,14 @@ void main() { await c.showDemo(); await tester.pumpWidget(HepanApp(controller: c)); await tester.pumpAndSettle(); - expect(find.text('Riverside Schedule'), findsOneWidget); + expect(find.text('RSchedule'), 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), + const Offset(185, 0), ); await tester.pumpAndSettle(); expect(find.text('登录学校账户'), findsOneWidget); @@ -32,11 +32,11 @@ void main() { expect(find.text('登录学校账户'), findsOneWidget); await tester.drag( find.byKey(const Key('dock-cursor')), - const Offset(-100, 0), + const Offset(-185, 0), ); await tester.pumpAndSettle(); expect(find.text('示例课表'), findsOneWidget); - await tester.tap(find.bySemanticsLabel('我的')); + await tester.tap(find.bySemanticsLabel('个人设置')); await tester.pumpAndSettle(); expect(find.text('登录学校账户'), findsOneWidget); c.dispose(); diff --git a/mobile/test/widgets/logout_recovery_test.dart b/mobile/test/widgets/logout_recovery_test.dart new file mode 100644 index 0000000..e5d404c --- /dev/null +++ b/mobile/test/widgets/logout_recovery_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:timezone/data/latest.dart' as tzdata; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; +import 'package:hepan_everyday/app/hepan_app.dart'; +import 'package:hepan_everyday/features/school/school_session.dart'; + +import '../support/fake_school_web.dart'; +import 'navigation_test.dart' show uiController; + +void main() { + setUpAll(tzdata.initializeTimeZones); + testWidgets( + 'logout failure is visible and user can retry clearing school login', + (tester) async { + final platform = FakeSchoolPlatform(); + WebViewPlatform.instance = platform; + final school = SchoolSession(); + await school.open(); + final c = uiController(); + await c.initialize(); + await tester.pumpWidget(HepanApp(controller: c, school: school)); + await tester.pumpAndSettle(); + await tester.tap(find.bySemanticsLabel('个人设置')); + await tester.pumpAndSettle(); + platform.cookies.fail = true; + await tester.tap(find.text('退出学校登录')); + await tester.pumpAndSettle(); + await tester.tap(find.text('保留课表和考试')); + await tester.pumpAndSettle(); + expect(find.textContaining('学校退出未完成'), findsOneWidget); + expect(find.text('重试退出学校登录'), findsOneWidget); + platform.cookies.fail = false; + await tester.tap(find.text('重试退出学校登录')); + await tester.pumpAndSettle(); + await tester.tap(find.text('保留课表和考试')); + await tester.pumpAndSettle(); + expect(find.text('重试退出学校登录'), findsNothing); + expect(find.textContaining('学校退出未完成'), findsNothing); + school.dispose(); + c.dispose(); + }, + ); +} diff --git a/mobile/test/widgets/navigation_test.dart b/mobile/test/widgets/navigation_test.dart index 54ada90..fa36e2a 100644 --- a/mobile/test/widgets/navigation_test.dart +++ b/mobile/test/widgets/navigation_test.dart @@ -21,8 +21,8 @@ void main() { await controller.initialize(); await tester.pumpWidget(HepanApp(controller: controller)); await tester.pumpAndSettle(); - expect(find.bySemanticsLabel('我的'), findsOneWidget); - await tester.tap(find.bySemanticsLabel('我的')); + expect(find.bySemanticsLabel('个人设置'), findsOneWidget); + await tester.tap(find.bySemanticsLabel('个人设置')); await tester.pumpAndSettle(); expect(find.text('登录学校账户'), findsOneWidget); expect(find.text('课表设置'), findsOneWidget); diff --git a/mobile/test/widgets/recovery_test.dart b/mobile/test/widgets/recovery_test.dart index 9bbfb1c..ae7ba8c 100644 --- a/mobile/test/widgets/recovery_test.dart +++ b/mobile/test/widgets/recovery_test.dart @@ -37,7 +37,7 @@ void main() { await tester.tap(find.text('保留损坏文件并重新建立')); await tester.pumpAndSettle(); expect(controller.storageReady, isTrue); - await tester.tap(find.bySemanticsLabel('我的')); + await tester.tap(find.bySemanticsLabel('个人设置')); await tester.pumpAndSettle(); await tester.ensureVisible(find.text('课表设置')); await tester.pumpAndSettle(); diff --git a/mobile/test/widgets/rschedule_navigation_test.dart b/mobile/test/widgets/rschedule_navigation_test.dart new file mode 100644 index 0000000..a3ac3d8 --- /dev/null +++ b/mobile/test/widgets/rschedule_navigation_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.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('three icon tabs and vertical scroll hide and restore Dock', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final c = uiController(); + await c.initialize(); + await c.showDemo(); + await tester.pumpWidget(HepanApp(controller: c)); + await tester.pumpAndSettle(); + expect(find.text('RSchedule'), findsOneWidget); + expect(find.bySemanticsLabel('考试'), findsOneWidget); + await tester.drag(find.text('周三'), const Offset(0, -200)); + await tester.pumpAndSettle(); + expect(find.bySemanticsLabel('考试').hitTestable(), findsNothing); + await tester.drag(find.byType(ListView).first, const Offset(0, 100)); + await tester.pumpAndSettle(); + expect(find.bySemanticsLabel('考试').hitTestable(), findsOneWidget); + await tester.tap(find.bySemanticsLabel('考试')); + await tester.pumpAndSettle(); + expect(find.text('考试安排'), findsOneWidget); + await tester.drag(find.text('考试安排'), const Offset(-200, 0)); + await tester.pumpAndSettle(); + expect(find.text('考试安排'), findsOneWidget); + await tester.tap(find.bySemanticsLabel('个人设置')); + await tester.pumpAndSettle(); + expect(find.text('登录学校账户'), findsOneWidget); + c.dispose(); + }); +} diff --git a/mobile/test/widgets/settings_test.dart b/mobile/test/widgets/settings_test.dart index c2180fc..16b6f3d 100644 --- a/mobile/test/widgets/settings_test.dart +++ b/mobile/test/widgets/settings_test.dart @@ -99,8 +99,8 @@ void main() { await controller.initialize(); await tester.pumpWidget(HepanApp(controller: controller)); await tester.pumpAndSettle(); - expect(find.bySemanticsLabel('我的'), findsOneWidget); - await tester.tap(find.bySemanticsLabel('我的')); + expect(find.bySemanticsLabel('个人设置'), findsOneWidget); + await tester.tap(find.bySemanticsLabel('个人设置')); await tester.pumpAndSettle(); await tester.ensureVisible(find.text('课表设置')); await tester.pumpAndSettle(); diff --git a/mobile/test/widgets/startup_test.dart b/mobile/test/widgets/startup_test.dart index 6cdd359..43d1de2 100644 --- a/mobile/test/widgets/startup_test.dart +++ b/mobile/test/widgets/startup_test.dart @@ -39,8 +39,8 @@ void main() { ); await tester.pumpAndSettle(); expect(find.bySemanticsLabel('课表'), findsOneWidget); - expect(find.bySemanticsLabel('我的'), findsOneWidget); - await tester.tap(find.bySemanticsLabel('我的')); + expect(find.bySemanticsLabel('个人设置'), findsOneWidget); + await tester.tap(find.bySemanticsLabel('个人设置')); await tester.pumpAndSettle(); selection.value = 'math/2026-09-09'; await tester.pumpAndSettle(); diff --git a/scripts/build-android.ps1 b/scripts/build-android.ps1 index 71762bb..139232a 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/Riverside-Schedule-debug.apk" -Force - Get-Item -LiteralPath "$projectRoot/dist/Riverside-Schedule-debug.apk" | Select-Object FullName,Length,LastWriteTime + Copy-Item -LiteralPath $built -Destination "$projectRoot/dist/RSchedule-debug.apk" -Force + Get-Item -LiteralPath "$projectRoot/dist/RSchedule-debug.apk" | Select-Object FullName,Length,LastWriteTime } finally { Pop-Location }