diff --git a/README.md b/README.md index cfbef95..320b601 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # RSchedule -电子科技大学研究生课表 APP,Flutter 实现,Android 0.3.2+6,Android 7.0 及以上。 +电子科技大学研究生课表 APP,Flutter 实现,Android 0.4.0+7,Android 7.0 及以上。 三个图标标签页:课表、考试、个人设置。界面统一蓝色,三个页面标题对齐。透明蓝色液态玻璃风格悬浮 Dock 缩窄至264,选中滑块为灰色玻璃,图标为实心蓝色,静止边距为3。支持点击图标或拖动选中滑块切换;按压和拖动时放大、松手弹簧回弹,支持的渲染模式启用实时背景折射。页面切换采用短距离移动与淡入淡出,并保留每页滚动位置。页面本身不支持左右滑动切换。课表和考试页面向下滚动时 Dock 用360ms过渡滑出屏幕底部,向上滚动恢复;个人设置页面保持显示。系统“减少动画”开启时减少切页与回弹动画。 @@ -10,7 +10,11 @@ 安装 `dist/RSchedule-debug.apk`,可覆盖旧版并保留本地课表与设置。内部 Dart 包名和 Android 包名保留不变以兼容升级。 -在个人设置中打开学校账户。应用先访问 [研究生系统专用认证入口](https://yjsjy.uestc.edu.cn/pyxx/jzsso/login),学生在学校原网页填写账号、密码和验证码;应用没有自己的密码表单,不读取或注入密码。学校返回研究生系统后显示“正在查找课表,请稍候”,自动打开 [课表查询](https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1),识别完整表格后导入保存。 +在个人设置中打开学校账户。应用先访问 [研究生系统专用认证入口](https://yjsjy.uestc.edu.cn/pyxx/jzsso/login),学生在学校原网页填写账号、密码和验证码;应用没有自己的密码表单,不读取或注入密码。首次认证完成后选择学校学期,确认后显示“正在查找课表,请稍候”,自动打开对应课表查询并导入保存。 + +学期73为2026-2027年秋、72为2025-2026年春、71为2025-2026年秋。课表路径为 `/pyxx/pygl/xskbcx/index/{学期编号}`;考试路径为 `/pyxx/pygl/kskcxx/index?xq={学期编号}`。选择成功后保存在本机,课表右上角刷新及考试更新沿用该学期。「个人设置 → 更新学期」已连接时立即选择,未连接时先认证。读取的 HTML 必须与所选学期一致,取消或保存失败不改原数据。 + +成功导入不同学期会移除另一个页面的旧学期数据并清除旧开始日,避免混用;同学期刷新保留开始日。课表页在已导入但未设置开始日时,显示课程数量及“设置学期开始日”入口,直接进入日期设置。页面标题分别为课表、考试、RSchedule。 专用入口的原始302响应指向 HTTP 身份认证地址,携带研究生系统的 `service` 参数。应用将该身份认证地址转到 HTTPS 并完整保留参数。学校 HTTP 回调仅允许准确的研究生系统域名。读取或保存失败保留已有数据,可在学校认证页面重试。 @@ -18,7 +22,7 @@ ## 考试安排 -考试页右上角可从学校更新,或导入学校考试查询页保存的 HTML。查询路径为 `/pyxx/pygl/kskcxx/index`,由用户提供的导出页面确认。考试数据独立保存,不覆盖课表,也不产生考试提醒。显示课程名称、考试时间、考场、座位号;有考核方式、考生编号或考场顺序号时一并显示。 +考试页右上角可从学校更新,或导入学校考试查询页保存的 HTML。查询路径由用户提供的导出页面确认。考试数据独立保存,同学期更新不覆盖课表,也不产生考试提醒。显示课程名称、考试时间、考场、座位号;有考核方式、考生编号或考场顺序号时一并显示。 提供的考试 HTML 含2026-2027秋学期8门课程,排考字段全部为空。当前显示“尚未安排”,不猜测考试时间和地点。未知或不完整表格拒绝导入;只有明确的无数据提示才接受空列表。原始附件与其中的学号、姓名不随源码或 APK 分发。 @@ -26,7 +30,9 @@ 「个人设置 → 从文件导入」支持学校课表 `.html` / `.htm` 与版本1的 JSON,先预览再确认;考试文件从考试页导入。文件最多2MB,UTF-8编码;其中脚本不会执行。 -HTML没有学期开始日,仍须在课表设置填写实际开始日和学期周数。默认课前15分钟提醒,可选5/10/15/30/60分钟。启用通知和手机日历时按提示授权。JSON样例:[schedule.schema-v1.json](examples/schedule.schema-v1.json)。示例课表不覆盖本人数据,不创建通知或日历事件。 +HTML没有学期开始日,仍须在课表设置填写实际开始日和学期周数。离线文件只有保存时那个学期的数据,预览显示实际学期;其他学期通过学校在线查询获取。JSON 支持可选 `schoolTerm: {"id": 73}` 并兼容旧课表编号推断,不允许混合学校学期。默认课前15分钟提醒,可选5/10/15/30/60分钟。启用通知和手机日历时按提示授权。JSON样例:[schedule.schema-v1.json](examples/schedule.schema-v1.json)。示例课表不覆盖本人数据,不创建通知或日历事件。 + +0.4.0 修复点按滑块重复回弹及速度形变:点按平滑移动,拖动和按住时放大约30%,松手弹性回位。Dock 底座与滑块分别使用实时背景折射。兼容模式仍使用透明玻璃效果,详见 [0.4.0验证记录](docs/rschedule-0.4-validation.md)。 ## 开发 diff --git a/docs/rschedule-0.4-validation.md b/docs/rschedule-0.4-validation.md new file mode 100644 index 0000000..b100a84 --- /dev/null +++ b/docs/rschedule-0.4-validation.md @@ -0,0 +1,34 @@ +# RSchedule Android 0.4.0+7 验证 + +日期:2026-10-13。沿用 Android 包名与签名,可覆盖安装;最低 Android 7 / API24。 + +## 修改与原因 + +- 点按 Dock 原先在选择回调及父组件更新各启动一次回弹,还将移动速度用于形变。现在点按采用单次320ms平滑移动,拖动松手保留弹性;只有已选滑块按住时放大,宽高增幅约30%。灰色静止玻璃、蓝色实心图标保留。 +- 底座和滑块各自采用独立实时背景折射滤镜;不支持的渲染方式使用兼容玻璃效果。 +- 课表页右上角刷新与考试更新共用本机保存的 SchoolTerm。首次在线导入先认证,再选择学期;已连接的“更新学期”立即显示选择,未连接时先认证。 +- 学期编号73为2026-2027年秋,72为2025-2026年春,71为2025-2026年秋。课表使用路径编号,考试使用xq查询参数;认证后自动跳转目标。实际HTML选中学期不符时拒绝保存。网址目标判断同时比较xq,避免默认考试页阻止跳转。 +- 成功导入另一学期会清除另一个页面的旧学期数据和旧教学开始日;同学期刷新保留。选择确认后仅发出请求,读取和首次写入成功后才持久化,取消或失败不替换旧数据。 +- 学校学期与教学日期独立保存。HTML及JSON导入带入实际学期,JSON兼容旧学校课程编号;拒绝混合学期。旧保存文件可从学校课程编号/考试termId恢复学期。 +- 课表页标题为课表、考试页为考试并保留考试安排标题;个人页保持RSchedule。缺少开始日时显示已保存课程数量和设置按钮,按钮定位学期日期区域。 +- 离线HTML只包含保存时的学期,确认页明确说明;查询其他学期使用在线连接。 + +## 自动验证 + +- 完整 Flutter 测试130项通过,含原始课表/考试附件解析。附件仅由本机环境变量提供,不加入源码或APK。 +- 静态分析:No issues found。 +- 精确生产WebView脚本的Node测试通过:身份认证证据、活跃下拉选项、受信源、内嵌表格与排除密码页,并检查学期选项。 +- 新回归覆盖逐帧点按位置单调且不改变尺寸、未选图标按住不放大、选中滑块放大超过25%、底座与滑块两个折射表面、减弱动画、学期路径及查询编号、持久化、旧数据升级、同学期刷新、跨学期隔离、写入失败、选择前不导入、认证后选择、取消、HTML学期不一致与考试默认页跳转。 +- Android debug构建成功。APK显示versionName0.4.0/versionCode7,minSdk24/targetSdk36。 + +## 安卓实际检查 + +API36 Pixel7模拟器成功覆盖安装,原11条课程、8条考试和课程条长度设置保留。课表七列、课表与考试标题、刷新按钮、个人设置更新学期入口已查看。暂时将模拟器测试数据的开始日清空,查看首页提示并点击直接进入日期设置,随后恢复备份。较长拖动时拍到明显放大、变透明的滑块。 + +截图在dist/screenshots:rschedule-week.png、rschedule-exams.png、rschedule-profile.png、rschedule-start-date.png、rschedule-date-settings.png、rschedule-dock-held.png。截图中教学日期是验证用日期,不代表学校实际开学日期,也不预置到交付包。 + +本机模拟器GPU驱动限制仍存在,检查时仅以启动参数启用软件渲染并关闭Impeller,交付APK保留手机默认渲染配置。实时折射算法继续通过实际图像像素测试;本次模拟器截图展示兼容玻璃效果,不能作为手机GPU折射验证。 + +未获取用户真实登录凭据,因此未完成真实学生SSO/验证码及多学期在线查询的端到端验证;这部分仍需用户本人手机测试。 + +APK SHA256:A1B581A4FC8B286BA5AE22A1383701C2D762FD0AC6BCAEFF0249BD11518C655D。 diff --git a/docs/superpowers/plans/2026-10-13-term-and-dock-revision.md b/docs/superpowers/plans/2026-10-13-term-and-dock-revision.md new file mode 100644 index 0000000..d3ce412 --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-term-and-dock-revision.md @@ -0,0 +1,12 @@ +# RSchedule 学期导入与 Dock 修订 + +依据用户明确提出的现有功能修改,继续本地 master 的交付流程。 + +1. 独立修复 Dock 点按重复动画与速度形变;拖动保留弹性、加大光学镜片,底座也使用独立折射滤镜。用逐帧位置、快速切换、拖动尺寸及减弱动态效果测试验证。 +2. 增加 SchoolTerm(学校学期编号、名称、两个查询地址),作为本机保存的可选字段,兼容旧保存文件。学校 HTML 导入携带实际选中学期,考试导入使用 termId。 +3. 第一次连接或更新学期:先完成官方网页身份认证,再选择学期并跳转指定地址;已连接可立即选择。刷新复用已保存学期,读取结果必须匹配目标学期。 +4. 成功导入其他学期时清除旧学期另一类数据及开始日;失败保留原数据。同学期刷新保留日期。重新协调课程通知和手机日历。 +5. 课表页增加刷新和已导入但缺日期的提示/设置按钮;标题改为课表/考试,个人页保留 RSchedule 并增加更新学期。 +6. 测试模型持久化、旧文件兼容、切换学期隔离、保存失败、WebView 选择/刷新/过期认证及页面提示。运行完整测试、静态分析与 Android 构建,检查原生画面,更新 APK、源码和验证记录。 + +离线 HTML 只有保存时那个学期的数据,确认导入时说明其实际学期;其他学期必须通过学校在线查询获取,避免给原有数据改标签。 diff --git a/mobile/lib/app/app_controller.dart b/mobile/lib/app/app_controller.dart index dc0f75b..832919b 100644 --- a/mobile/lib/app/app_controller.dart +++ b/mobile/lib/app/app_controller.dart @@ -9,6 +9,7 @@ import '../services/calendar_service.dart'; import '../domain/schedule_engine.dart'; import '../data/demo_schedule.dart'; import '../domain/exam_schedule.dart'; +import '../domain/school_term.dart'; class AppController extends ChangeNotifier { AppController(this.store, this.notifications, this.calendar, this.clock); @@ -145,6 +146,11 @@ class AppController extends ChangeNotifier { await _save( snapshot.copyWith( courses: draft.courses, + schoolTerm: draft.schoolTerm, + clearExams: _differentTerm(draft.schoolTerm), + settings: _differentTerm(draft.schoolTerm) + ? snapshot.settings.copyWith(clearSemester: true) + : snapshot.settings, importMetadata: ImportMetadata( source: draft.source, importedAt: clock(), @@ -177,8 +183,29 @@ class AppController extends ChangeNotifier { }); Future applyExams(ExamSchedule value) => _run(() async { final validated = ExamSchedule.fromJson(value.toJson()); - await _save(snapshot.copyWith(examSchedule: validated)); + final term = SchoolTerm(int.parse(validated.termId)); + final changed = _differentTerm(term); + await _save( + snapshot.copyWith( + examSchedule: validated, + schoolTerm: term, + courses: changed ? [] : null, + clearMetadata: changed, + settings: changed + ? snapshot.settings.copyWith(clearSemester: true) + : null, + ), + ); + if (changed) { + isDemo = false; + await _reconcile(); + } }); + bool _differentTerm(SchoolTerm? next) => + next != null && + ((snapshot.schoolTerm != null && snapshot.schoolTerm != next) || + (snapshot.schoolTerm == null && + (snapshot.courses.isNotEmpty || snapshot.examSchedule != null))); Future clearExams() => _run(() async { await _save(snapshot.copyWith(clearExams: true)); }); diff --git a/mobile/lib/app/hepan_app.dart b/mobile/lib/app/hepan_app.dart index ec46097..c61dd1e 100644 --- a/mobile/lib/app/hepan_app.dart +++ b/mobile/lib/app/hepan_app.dart @@ -136,12 +136,19 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { } } - void _school({bool exams = false}) { + void _school({bool exams = false, bool chooseTerm = false}) { if (widget.schoolBuilder != null) { + final term = widget.controller.snapshot.schoolTerm; if (exams) { - widget.school?.openExams(); + widget.school?.openExams( + term: term, + chooseTerm: chooseTerm || term == null, + ); } else { - widget.school?.openTimetable(); + widget.school?.openTimetable( + term: term, + chooseTerm: chooseTerm || term == null, + ); } Navigator.push( context, @@ -272,7 +279,10 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { child: TabDeck( index: _tab, children: [ - TimetablePage(controller: widget.controller), + TimetablePage( + controller: widget.controller, + onSync: _school, + ), ExamsPage( controller: widget.controller, onSync: () { @@ -282,6 +292,7 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { ProfilePage( controller: widget.controller, onLogin: _school, + onUpdateTerm: () => _school(chooseTerm: true), authStatus: widget.school?.statusLabel ?? '未登录', logoutLabel: widget.school?.logoutFailed == true ? '重试退出学校登录' diff --git a/mobile/lib/data/json_schedule_import.dart b/mobile/lib/data/json_schedule_import.dart index bcfd193..64008c5 100644 --- a/mobile/lib/data/json_schedule_import.dart +++ b/mobile/lib/data/json_schedule_import.dart @@ -1,4 +1,5 @@ import '../domain/course.dart'; +import '../domain/school_term.dart'; import 'dart:convert'; @@ -9,12 +10,14 @@ class ImportDraft { required this.source, required this.parserVersion, this.semesterLabel, + this.schoolTerm, }); final List courses; final bool explicitlyEmpty; final CourseSource source; final String parserVersion; final String? semesterLabel; + final SchoolTerm? schoolTerm; } class JsonScheduleImport { @@ -46,11 +49,28 @@ class JsonScheduleImport { if (courses.map((c) => c.id).toSet().length != courses.length) { throw const FormatException('课程标识重复'); } + final termIds = courses + .map((c) => RegExp(r'^uestc-(\d+)-').firstMatch(c.id)?.group(1)) + .whereType() + .toSet(); + final term = json['schoolTerm'] != null + ? SchoolTerm.fromJson( + Map.from(json['schoolTerm'] as Map), + ) + : termIds.length == 1 + ? SchoolTerm(int.parse(termIds.single)) + : null; + if (termIds.length > 1 || + (term != null && termIds.any((id) => id != term.id.toString()))) { + throw const FormatException('文件中包含不同学期的课程'); + } return ImportDraft( courses: List.unmodifiable(courses), explicitlyEmpty: json['explicitlyEmpty'] == true, source: source, parserVersion: 'json-v1', + schoolTerm: source == CourseSource.demo ? null : term, + semesterLabel: term?.label, ); } on FormatException { rethrow; diff --git a/mobile/lib/domain/app_settings.dart b/mobile/lib/domain/app_settings.dart index 2a9c5b8..8203f97 100644 --- a/mobile/lib/domain/app_settings.dart +++ b/mobile/lib/domain/app_settings.dart @@ -24,6 +24,7 @@ class AppSettings { final String? calendarId; AppSettings copyWith({ Semester? semester, + bool clearSemester = false, bool? remindersEnabled, int? reminderMinutes, ColorMode? colorMode, @@ -32,7 +33,7 @@ class AppSettings { String? calendarId, double? courseHeightScale, }) => AppSettings( - semester: semester ?? this.semester, + semester: clearSemester ? null : semester ?? this.semester, remindersEnabled: remindersEnabled ?? this.remindersEnabled, reminderMinutes: reminderMinutes ?? this.reminderMinutes, colorMode: colorMode ?? this.colorMode, diff --git a/mobile/lib/domain/app_snapshot.dart b/mobile/lib/domain/app_snapshot.dart index 86d2bf6..b69dd46 100644 --- a/mobile/lib/domain/app_snapshot.dart +++ b/mobile/lib/domain/app_snapshot.dart @@ -2,6 +2,7 @@ import 'app_settings.dart'; import 'course.dart'; import 'semester.dart'; import 'exam_schedule.dart'; +import 'school_term.dart'; class ImportMetadata { const ImportMetadata({ @@ -20,16 +21,32 @@ class AppSnapshot { List courses = const [], this.importMetadata, this.examSchedule, + SchoolTerm? schoolTerm, this.calendarCleanupPending = false, Map calendarEventIds = const {}, - }) : courses = List.unmodifiable(courses), + }) : schoolTerm = schoolTerm ?? _legacyTerm(courses, examSchedule), + courses = List.unmodifiable(courses), calendarEventIds = Map.unmodifiable(calendarEventIds); final AppSettings settings; final List courses; final ImportMetadata? importMetadata; final ExamSchedule? examSchedule; + final SchoolTerm? schoolTerm; final Map calendarEventIds; final bool calendarCleanupPending; + static SchoolTerm? _legacyTerm(List courses, ExamSchedule? exams) { + final ids = courses + .map( + (course) => RegExp(r'^uestc-(\d+)-').firstMatch(course.id)?.group(1), + ) + .whereType() + .toSet(); + if (exams != null) ids.add(exams.termId); + if (ids.length != 1) return null; + final id = int.tryParse(ids.single); + return id != null && id >= 1 && id <= 9999 ? SchoolTerm(id) : null; + } + AppSnapshot copyWith({ AppSettings? settings, List? courses, @@ -38,6 +55,7 @@ class AppSnapshot { bool clearMetadata = false, ExamSchedule? examSchedule, bool clearExams = false, + SchoolTerm? schoolTerm, bool? calendarCleanupPending, }) => AppSnapshot( settings: settings ?? this.settings, @@ -46,6 +64,7 @@ class AppSnapshot { ? null : importMetadata ?? this.importMetadata, examSchedule: clearExams ? null : examSchedule ?? this.examSchedule, + schoolTerm: schoolTerm ?? this.schoolTerm, calendarEventIds: calendarEventIds ?? this.calendarEventIds, calendarCleanupPending: calendarCleanupPending ?? this.calendarCleanupPending, @@ -53,6 +72,7 @@ class AppSnapshot { Map toJson() => { 'schemaVersion': 1, 'examSchedule': examSchedule?.toJson(), + 'schoolTerm': schoolTerm?.toJson(), 'courses': courses.map((c) => c.toJson()).toList(), 'settings': { 'semester': settings.semester == null @@ -116,6 +136,11 @@ class AppSnapshot { ); final metadata = json['importMetadata']; return AppSnapshot( + schoolTerm: json['schoolTerm'] == null + ? null + : SchoolTerm.fromJson( + Map.from(json['schoolTerm'] as Map), + ), examSchedule: json['examSchedule'] == null ? null : ExamSchedule.fromJson( diff --git a/mobile/lib/domain/school_term.dart b/mobile/lib/domain/school_term.dart new file mode 100644 index 0000000..9340440 --- /dev/null +++ b/mobile/lib/domain/school_term.dart @@ -0,0 +1,27 @@ +/// The graduate system's term number, independent of teaching-week dates. +class SchoolTerm { + SchoolTerm(this.id) { + if (id < 1 || id > 9999) throw ArgumentError('学校学期编号无效'); + } + final int id; + String get label { + final year = 1990 + ((id - 1) ~/ 2); + return '$year-${year + 1}年${id.isOdd ? '秋' : '春'}'; + } + + static List get choices => + List.generate(73, (index) => SchoolTerm(73 - index)); + Uri get timetableUri => + Uri.https('yjsjy.uestc.edu.cn', '/pyxx/pygl/xskbcx/index/$id'); + Uri get examUri => + Uri.https('yjsjy.uestc.edu.cn', '/pyxx/pygl/kskcxx/index', {'xq': '$id'}); + Map toJson() => {'id': id}; + factory SchoolTerm.fromJson(Map json) { + if (json['id'] is! int) throw const FormatException('学校学期无效'); + return SchoolTerm(json['id'] as int); + } + @override + bool operator ==(Object other) => other is SchoolTerm && other.id == id; + @override + int get hashCode => id.hashCode; +} diff --git a/mobile/lib/features/exams/exams_page.dart b/mobile/lib/features/exams/exams_page.dart index 9f7528d..6be875d 100644 --- a/mobile/lib/features/exams/exams_page.dart +++ b/mobile/lib/features/exams/exams_page.dart @@ -35,7 +35,7 @@ class ExamsPage extends StatelessWidget { builder: (context) => AlertDialog( title: const Text('导入考试信息'), content: Text( - '${draft.semesterLabel} · ${draft.records.length} 门课程\n将更新本机考试列表,尚未公布的字段显示为“尚未安排”。', + '${draft.semesterLabel} · ${draft.records.length} 门课程\n文件仅包含这个学期;其他学期请从学校查询。将更新本机考试列表,尚未公布的字段显示为“尚未安排”。', ), actions: [ TextButton( @@ -78,7 +78,7 @@ class ExamsPage extends StatelessWidget { ), children: [ const Text( - 'RSchedule', + '考试', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), ), const SizedBox(height: 18), diff --git a/mobile/lib/features/profile/import_preview.dart b/mobile/lib/features/profile/import_preview.dart index 096efb6..3b376eb 100644 --- a/mobile/lib/features/profile/import_preview.dart +++ b/mobile/lib/features/profile/import_preview.dart @@ -41,6 +41,14 @@ class _ImportPreviewState extends State { style: const TextStyle(color: river), ), if (draft.semesterLabel != null) Text('学期:${draft.semesterLabel}'), + if (draft.schoolTerm != null) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + '文件仅包含这个学期;查询其他学期请使用学校连接中的“更新学期”。', + style: TextStyle(fontSize: 12, height: 1.6), + ), + ), 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 74ed83b..a3b7263 100644 --- a/mobile/lib/features/profile/profile_page.dart +++ b/mobile/lib/features/profile/profile_page.dart @@ -18,10 +18,12 @@ class ProfilePage extends StatelessWidget { this.authStatus = '未登录', this.onLogout, this.logoutLabel = '退出学校登录', + this.onUpdateTerm, }); final AppController controller; final VoidCallback onLogin; final VoidCallback? onLogout; + final VoidCallback? onUpdateTerm; final String authStatus, logoutLabel; Future _import(BuildContext context) async { try { @@ -190,6 +192,13 @@ class ProfilePage extends StatelessWidget { ), ), Divider(height: 1, color: ink.withValues(alpha: 0.06)), + _tile( + Icons.date_range_rounded, + '更新学期', + controller.snapshot.schoolTerm?.label ?? '选择学校查询学期', + onUpdateTerm ?? onLogin, + ), + Divider(height: 1, color: ink.withValues(alpha: 0.06)), _tile( Icons.file_download_outlined, '从文件导入', diff --git a/mobile/lib/features/profile/settings_page.dart b/mobile/lib/features/profile/settings_page.dart index 31290ec..ee5baeb 100644 --- a/mobile/lib/features/profile/settings_page.dart +++ b/mobile/lib/features/profile/settings_page.dart @@ -8,8 +8,13 @@ import '../../domain/semester.dart'; import '../../services/notification_service.dart'; class SettingsPage extends StatefulWidget { - const SettingsPage({super.key, required this.controller}); + const SettingsPage({ + super.key, + required this.controller, + this.focusSemester = false, + }); final AppController controller; + final bool focusSemester; @override State createState() => _SettingsPageState(); } @@ -20,6 +25,7 @@ class _SettingsPageState extends State { double? _courseHeightDraft; bool _savingHeight = false; String? _weeksError, _dateError; + final _scroll = ScrollController(); @override void initState() { super.initState(); @@ -27,11 +33,19 @@ class _SettingsPageState extends State { _weeks = TextEditingController( text: '${widget.controller.snapshot.settings.semester?.totalWeeks ?? 20}', ); + if (widget.focusSemester) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _scroll.hasClients) { + _scroll.jumpTo(_scroll.position.maxScrollExtent); + } + }); + } } @override void dispose() { _weeks.dispose(); + _scroll.dispose(); super.dispose(); } @@ -132,6 +146,7 @@ class _SettingsPageState extends State { ), ), body: ListView( + controller: _scroll, padding: const EdgeInsets.fromLTRB(20, 12, 20, 40), children: [ if (c.isDemo) diff --git a/mobile/lib/features/school/school_auth_page.dart b/mobile/lib/features/school/school_auth_page.dart index eb97b6b..7283841 100644 --- a/mobile/lib/features/school/school_auth_page.dart +++ b/mobile/lib/features/school/school_auth_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:webview_flutter/webview_flutter.dart'; import '../../app/app_controller.dart'; +import '../../domain/school_term.dart'; import 'school_session.dart'; class SchoolAuthPage extends StatefulWidget { @@ -18,6 +19,7 @@ class SchoolAuthPage extends StatefulWidget { class _SchoolAuthPageState extends State { bool _saving = false; + SchoolTerm? _termChoice; @override void initState() { super.initState(); @@ -36,7 +38,9 @@ class _SchoolAuthPageState extends State { final courses = s.pendingDraft, exams = s.pendingExams; final generation = s.pageGeneration; final route = ModalRoute.of(context); - if (_saving || (courses == null && exams == null)) return; + if (s.needsTermSelection || _saving || (courses == null && exams == null)) { + return; + } _saving = true; WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted) { @@ -102,7 +106,9 @@ class _SchoolAuthPageState extends State { ), IconButton( tooltip: '读取当前页面', - onPressed: _saving ? null : s.readCurrentPage, + onPressed: _saving || s.needsTermSelection + ? null + : s.readCurrentPage, icon: const Icon(Icons.sync_rounded), ), ], @@ -123,7 +129,9 @@ class _SchoolAuthPageState extends State { child: Stack( children: [ WebViewWidget(controller: s.web), - if (s.finding || _saving) + if (s.needsTermSelection) + Positioned.fill(child: _termPicker(s)), + if (!s.needsTermSelection && (s.finding || _saving)) Positioned.fill( child: ColoredBox( color: const Color(0xFFF5F9FE), @@ -152,4 +160,62 @@ class _SchoolAuthPageState extends State { ), ); } + + Widget _termPicker(SchoolSession session) { + final choices = session.termChoices; + final preferred = + _termChoice ?? + session.selectedTerm ?? + widget.controller.snapshot.schoolTerm ?? + choices.first; + final selected = choices.contains(preferred) ? preferred : choices.first; + return Material( + color: const Color(0xFFF5F9FE), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + '选择学校学期', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + Text(session.requestingExams ? '将读取这个学期的考试安排。' : '将读取这个学期的课程表。'), + const SizedBox(height: 12), + Expanded( + child: ListView.builder( + itemCount: choices.length, + itemBuilder: (context, index) { + final term = choices[index]; + final isSelected = term == selected; + return ListTile( + title: Text(term.label), + selected: isSelected, + leading: Icon( + isSelected + ? Icons.radio_button_checked + : Icons.radio_button_off, + ), + onTap: () => setState(() => _termChoice = term), + ); + }, + ), + ), + const SizedBox(height: 12), + FilledButton( + onPressed: () => session.selectTerm(selected), + child: const Text('导入所选学期'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + ], + ), + ), + ), + ); + } } diff --git a/mobile/lib/features/school/school_login_flow.dart b/mobile/lib/features/school/school_login_flow.dart index 39a14f1..036487c 100644 --- a/mobile/lib/features/school/school_login_flow.dart +++ b/mobile/lib/features/school/school_login_flow.dart @@ -25,7 +25,11 @@ class SchoolLoginFlow { _requested = false; return null; } - if (uri.path == _target!.path || + final atTarget = + uri.path == _target!.path && + (!_target!.queryParameters.containsKey('xq') || + uri.queryParameters['xq'] == _target!.queryParameters['xq']); + if (atTarget || _requested || _attempts >= 2 || !authenticated || diff --git a/mobile/lib/features/school/school_page_adapter.dart b/mobile/lib/features/school/school_page_adapter.dart index 8405b78..455c57a 100644 --- a/mobile/lib/features/school/school_page_adapter.dart +++ b/mobile/lib/features/school/school_page_adapter.dart @@ -5,6 +5,7 @@ import 'package:html/parser.dart' as html; import '../../data/json_schedule_import.dart'; import '../../domain/course.dart'; +import '../../domain/school_term.dart'; import 'school_navigation_policy.dart'; class UnsupportedSchoolLayout implements Exception { @@ -112,6 +113,7 @@ class SchoolPageAdapter { source: source, parserVersion: 'uestc-graduate-html-v1', semesterLabel: selected.text.trim(), + schoolTerm: SchoolTerm(int.parse(term)), ); } on UnsupportedSchoolLayout { rethrow; diff --git a/mobile/lib/features/school/school_page_probe.dart b/mobile/lib/features/school/school_page_probe.dart index dc33698..7c210a1 100644 --- a/mobile/lib/features/school/school_page_probe.dart +++ b/mobile/lib/features/school/school_page_probe.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import '../../domain/school_term.dart'; import 'school_navigation_policy.dart'; /// Only serialize recognized table fragments, never a login document. @@ -14,7 +15,7 @@ const schoolPageProbeScript = r'''(() => { } catch (_) { return false; } }; const topUrl = location.href; - const result = {topUrl, authenticated: false, pages: []}; + const result = {topUrl, authenticated: false, pages: [], terms: []}; if (!trusted(topUrl) || document.querySelector('input[type="password"]')) { return JSON.stringify(result); } @@ -34,6 +35,13 @@ const schoolPageProbeScript = r'''(() => { ['timetable', '#tbl', '#drpxq'], ['exams', '#sample-table-1', '#xqbh'] ]) { const table = doc.querySelector(tableId), term = doc.querySelector(termId); + if (term && term.options && term.options.length <= 146) { + for (const option of Array.from(term.options)) { + const id = Number(option.value); + if (/^[1-9]\d*$/.test(option.value) && id <= 9999 && + !result.terms.includes(id) && result.terms.length < 146) result.terms.push(id); + } + } if (!table || !term || !term.options || term.selectedIndex < 0) continue; const selected = term.cloneNode(true); Array.from(selected.options).forEach((option, index) => { @@ -59,9 +67,14 @@ class SchoolTableFragment { } class SchoolPageEvidence { - const SchoolPageEvidence(this.authenticated, this.pages); + const SchoolPageEvidence( + this.authenticated, + this.pages, { + this.terms = const [], + }); final bool authenticated; final List pages; + final List terms; static SchoolPageEvidence decode(Object raw, Uri current) { dynamic value = raw; @@ -92,6 +105,22 @@ class SchoolPageEvidence { pages.add(SchoolTableFragment(page['kind'] as String, uri, content)); } } - return SchoolPageEvidence(value['authenticated'] == true, pages); + final terms = []; + final candidatesTerms = value['terms']; + if (candidatesTerms is List && candidatesTerms.length <= 146) { + final ids = + candidatesTerms + .whereType() + .where((id) => id >= 1 && id <= 9999) + .toSet() + .toList() + ..sort((a, b) => b.compareTo(a)); + terms.addAll(ids.map(SchoolTerm.new)); + } + return SchoolPageEvidence( + value['authenticated'] == true, + pages, + terms: terms, + ); } } diff --git a/mobile/lib/features/school/school_session.dart b/mobile/lib/features/school/school_session.dart index 113ed4c..f319d79 100644 --- a/mobile/lib/features/school/school_session.dart +++ b/mobile/lib/features/school/school_session.dart @@ -5,6 +5,7 @@ import 'package:webview_flutter/webview_flutter.dart'; import '../../data/json_schedule_import.dart'; import '../../domain/exam_schedule.dart'; +import '../../domain/school_term.dart'; import 'school_navigation_policy.dart'; import 'school_page_adapter.dart'; import 'school_exam_adapter.dart'; @@ -114,6 +115,23 @@ class SchoolSession extends ChangeNotifier { int _openSerial = 0; ImportDraft? pendingDraft; ExamSchedule? pendingExams; + SchoolTerm? selectedTerm; + bool _termSelectionRequested = true; + bool _termSelectionReady = false; + List _liveTerms = const []; + bool get needsTermSelection => + hasOpened && + connectionState == SchoolConnectionState.connected && + _termSelectionRequested && + _termSelectionReady; + List get termChoices { + final terms = [...(_liveTerms.isEmpty ? SchoolTerm.choices : _liveTerms)]; + if (selectedTerm != null && !terms.contains(selectedTerm)) { + terms.add(selectedTerm!); + } + return terms..sort((a, b) => b.id.compareTo(a.id)); + } + String message = '请在学校网页中完成登录与验证码。'; String get statusLabel => logoutFailed ? '学校退出未完成,请重试' @@ -130,18 +148,31 @@ class SchoolSession extends ChangeNotifier { 'https://yjsjy.uestc.edu.cn/pyxx/jzsso/login', ); static final timetableUrl = Uri.parse( - 'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1', + 'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/73', ); static final examUrl = Uri.parse( - 'https://yjsjy.uestc.edu.cn/pyxx/pygl/kskcxx/index', + 'https://yjsjy.uestc.edu.cn/pyxx/pygl/kskcxx/index?xq=73', ); - Uri get _target => requestingExams ? examUrl : timetableUrl; + Uri get _target => requestingExams + ? (selectedTerm?.examUri ?? examUrl) + : (selectedTerm?.timetableUri ?? 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 { + Future openTimetable({SchoolTerm? term, bool chooseTerm = false}) => + _open(exams: false, term: term, chooseTerm: chooseTerm); + Future openExams({SchoolTerm? term, bool chooseTerm = false}) => + _open(exams: true, term: term, chooseTerm: chooseTerm); + Future openAuthentication() => _open( + exams: requestingExams, + term: selectedTerm, + chooseTerm: _termSelectionRequested, + forceAuth: true, + ); + Future _open({ + required bool exams, + SchoolTerm? term, + bool chooseTerm = false, + bool forceAuth = false, + }) async { final serial = ++_openSerial; final closing = _logoutFuture; if (closing != null) await closing; @@ -154,6 +185,9 @@ class SchoolSession extends ChangeNotifier { final generation = _pageGeneration; pendingDraft = null; pendingExams = null; + selectedTerm = term; + _termSelectionRequested = chooseTerm || term == null; + _termSelectionReady = false; requestingExams = exams; logoutFailed = false; hasOpened = true; @@ -161,10 +195,17 @@ class SchoolSession extends ChangeNotifier { _flow.begin(_target); final connected = connectionState == SchoolConnectionState.connected && !forceAuth; - finding = connected; - message = connected ? _findingMessage : '请在学校网页中完成登录与验证码。'; - if (connected) _armTimeout(); + _termSelectionReady = connected; + finding = connected && !needsTermSelection; + _loading = !needsTermSelection; + message = needsTermSelection + ? '请选择要导入的学校学期。' + : connected + ? _findingMessage + : '请在学校网页中完成登录与验证码。'; + if (finding) _armTimeout(); notifyListeners(); + if (needsTermSelection) return; try { await web.loadRequest(connected ? _target : authUrl); } catch (_) { @@ -174,6 +215,39 @@ class SchoolSession extends ChangeNotifier { } } + Future selectTerm(SchoolTerm term) async { + if (!hasOpened || + !needsTermSelection || + connectionState != SchoolConnectionState.connected || + !termChoices.contains(term)) { + return; + } + final serial = ++_openSerial; + _poll?.cancel(); + _timeout?.cancel(); + _pageGeneration++; + _reader.invalidate(); + final generation = _pageGeneration; + pendingDraft = null; + pendingExams = null; + selectedTerm = term; + _termSelectionRequested = false; + _manualRead = false; + _flow.begin(_target); + finding = true; + _loading = true; + message = _findingMessage; + _armTimeout(); + notifyListeners(); + try { + await web.loadRequest(_target); + } catch (_) { + if (serial == _openSerial && generation == _pageGeneration) { + _failure('学校学期查询页面暂时无法打开,请重试。'); + } + } + } + String get _findingMessage => requestingExams ? '正在查找考试安排,请稍候' : '正在查找课表,请稍候'; void _armTimeout() { _timeout?.cancel(); @@ -223,6 +297,7 @@ class SchoolSession extends ChangeNotifier { if (!_readingActive || !hasOpened || generation != _pageGeneration || + needsTermSelection || pendingDraft != null || pendingExams != null) { return; @@ -258,6 +333,7 @@ class SchoolSession extends ChangeNotifier { _loading = false; if (pendingDraft != null || pendingExams != null) return; if (uri.host == 'idas.uestc.edu.cn') { + _termSelectionReady = false; _timeout?.cancel(); finding = false; connectionState = SchoolConnectionState.authenticating; @@ -275,7 +351,7 @@ class SchoolSession extends ChangeNotifier { final targetPage = requestingExams ? SchoolNavigationPolicy.isExam(uri) : SchoolNavigationPolicy.isTimetable(uri); - if (targetPage || _manualRead) { + if (!_termSelectionRequested && (targetPage || _manualRead)) { finding = true; message = _findingMessage; notifyListeners(); @@ -288,21 +364,33 @@ class SchoolSession extends ChangeNotifier { await web.currentUrl() != url) { return; } + if (evidence.terms.isNotEmpty) _liveTerms = evidence.terms; + if (_termSelectionRequested) { + if (evidence.authenticated || evidence.pages.isNotEmpty) { + connectionState = SchoolConnectionState.connected; + _termSelectionReady = true; + _timeout?.cancel(); + _poll?.cancel(); + finding = false; + message = '请选择要导入的学校学期。'; + notifyListeners(); + } + return; + } for (final page in evidence.pages) { if (page.kind != (requestingExams ? 'exams' : 'timetable')) continue; try { if (requestingExams) { final draft = SchoolExamAdapter.extract(page.html); if (generation != _pageGeneration) return; + if (draft.termId != selectedTerm?.id.toString()) continue; pendingExams = draft; } else { // The probe validates the live document's origin. The strict parser // gets only its recognized fragment with the canonical table path. - final draft = await _reader.read( - timetableUrl, - () async => page.html, - ); + final draft = await _reader.read(_target, () async => page.html); if (!hasOpened || generation != _pageGeneration) return; + if (draft.schoolTerm != selectedTerm) continue; pendingDraft = draft; } _timeout?.cancel(); @@ -347,6 +435,9 @@ class SchoolSession extends ChangeNotifier { } Future readTimetable() async { + if (_termSelectionRequested || selectedTerm == null) { + throw UnsupportedSchoolLayout(); + } final generation = _pageGeneration; final uri = Uri.tryParse(await web.currentUrl() ?? ''); if (uri == null || !SchoolNavigationPolicy.isGraduatePage(uri)) { @@ -360,7 +451,12 @@ class SchoolSession extends ChangeNotifier { for (final page in evidence.pages) { if (page.kind != 'timetable') continue; try { - return await _reader.read(timetableUrl, () async => page.html); + final draft = await _reader.read( + selectedTerm!.timetableUri, + () async => page.html, + ); + if (draft.schoolTerm != selectedTerm) continue; + return draft; } on UnsupportedSchoolLayout { if (generation != _pageGeneration) rethrow; } @@ -369,6 +465,7 @@ class SchoolSession extends ChangeNotifier { } Future readCurrentPage() async { + if (needsTermSelection) return; _poll?.cancel(); _timeout?.cancel(); _pageGeneration++; diff --git a/mobile/lib/features/timetable/timetable_page.dart b/mobile/lib/features/timetable/timetable_page.dart index 5436577..b40e26e 100644 --- a/mobile/lib/features/timetable/timetable_page.dart +++ b/mobile/lib/features/timetable/timetable_page.dart @@ -3,11 +3,13 @@ import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; import '../../app/app_theme.dart'; import '../../domain/schedule_engine.dart'; +import '../profile/settings_page.dart'; import 'week_grid.dart'; class TimetablePage extends StatefulWidget { - const TimetablePage({super.key, required this.controller}); + const TimetablePage({super.key, required this.controller, this.onSync}); final AppController controller; + final VoidCallback? onSync; @override State createState() => _TimetablePageState(); } @@ -40,13 +42,32 @@ class _TimetablePageState extends State { 140 + MediaQuery.paddingOf(context).bottom, ), children: [ - const Padding( + Padding( padding: EdgeInsets.symmetric(horizontal: 14), - child: Text( - 'RSchedule', - style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + child: Row( + children: [ + const Expanded( + child: Text( + '课表', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + ), + IconButton( + tooltip: '刷新课表', + onPressed: widget.onSync, + icon: const Icon(Icons.sync_rounded), + ), + ], ), ), + if (!c.isDemo && c.snapshot.schoolTerm != null) + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 8), + child: Text( + c.snapshot.schoolTerm!.label, + style: TextStyle(fontSize: 12, color: ink.withValues(alpha: 0.5)), + ), + ), const SizedBox(height: 10), SurfaceCard( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0), @@ -142,6 +163,51 @@ class _TimetablePageState extends State { textAlign: TextAlign.center, style: TextStyle(fontSize: 11, color: ink.withValues(alpha: 0.35)), ), + ] else if (c.visibleCourses.isNotEmpty) ...[ + const SizedBox(height: 36), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: SurfaceCard( + child: Column( + children: [ + const Icon( + Icons.event_available_rounded, + size: 44, + color: river, + ), + const SizedBox(height: 18), + const Text( + '课表已导入,请设置学期开始日', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 10), + Text( + '${c.visibleCourses.length} 条课程安排已保存', + style: const TextStyle(fontSize: 13, color: river), + ), + const SizedBox(height: 10), + const Text( + '课程按教学周保存。设置真实的学期开始日后,\n才能显示每周课表并安排课前提醒。', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, height: 1.8), + ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + SettingsPage(controller: c, focusSemester: true), + ), + ), + icon: const Icon(Icons.edit_calendar_rounded, size: 18), + label: const Text('设置学期开始日'), + ), + ], + ), + ), + ), ] else ...[ const SizedBox(height: 38), const Icon( diff --git a/mobile/lib/widgets/glass_dock.dart b/mobile/lib/widgets/glass_dock.dart index 4078e1d..366259b 100644 --- a/mobile/lib/widgets/glass_dock.dart +++ b/mobile/lib/widgets/glass_dock.dart @@ -30,6 +30,14 @@ class _GlassDockState extends State with TickerProviderStateMixin { value: 0, ); bool _reduceMotion = false; + late int _targetIndex; + bool _elasticMotion = false; + @override + void initState() { + super.initState(); + _targetIndex = widget.selectedIndex; + } + double get _pressure => _press.value.clamp(0.0, 1.18); void _pressTo(double target) { if (_reduceMotion) { @@ -38,7 +46,7 @@ class _GlassDockState extends State with TickerProviderStateMixin { } _press.animateWith( SpringSimulation( - const SpringDescription(mass: 1, stiffness: 440, damping: 20), + const SpringDescription(mass: 1, stiffness: 440, damping: 17), _press.value, target, _press.velocity, @@ -55,26 +63,36 @@ class _GlassDockState extends State with TickerProviderStateMixin { double? _drag; double _dragSpeed = 0; double get _position => _drag ?? _motion.value.clamp(-0.12, 2.12); - void _spring(int index, [double velocity = 0]) { + void _moveTo(int index, {bool elastic = false, double velocity = 0}) { + _targetIndex = index; + _elasticMotion = elastic && !_reduceMotion; if (_reduceMotion) { _motion.value = index.toDouble(); return; } - _motion.animateWith( - SpringSimulation( - const SpringDescription(mass: 1, stiffness: 380, damping: 23), - _position, + if (elastic) { + _motion.animateWith( + SpringSimulation( + const SpringDescription(mass: 1, stiffness: 380, damping: 19), + _position, + index.toDouble(), + velocity.clamp(-8, 8), + ), + ); + } else { + _motion.animateTo( index.toDouble(), - velocity.clamp(-8, 8), - ), - ); + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + ); + } } - void _select(int index, [double velocity = 0]) { + void _select(int index, {bool elastic = false, double velocity = 0}) { final position = _position; setState(() => _drag = null); _motion.value = position; - _spring(index, velocity); + _moveTo(index, elastic: elastic, velocity: velocity); _pressTo(0); widget.onSelected(index); } @@ -82,8 +100,10 @@ class _GlassDockState extends State with TickerProviderStateMixin { @override void didUpdateWidget(GlassDock oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.selectedIndex != widget.selectedIndex && _drag == null) { - _spring(widget.selectedIndex); + // The parent acknowledges onSelected with a rebuild. Restarting here used + // to erase the release velocity and start a second spring for the same tab. + if (widget.selectedIndex != _targetIndex && _drag == null) { + _moveTo(widget.selectedIndex); } } @@ -124,12 +144,10 @@ class _GlassDockState extends State with TickerProviderStateMixin { ); return ClipRRect( borderRadius: BorderRadius.circular(40), - child: lens - ? GlassRefraction(strength: 0.25 + pressure * 0.95, child: content) - : BackdropFilter( - filter: ui.ImageFilter.blur(sigmaX: 1.0, sigmaY: 1.0), - child: content, - ), + child: GlassRefraction( + strength: lens ? 0.25 + pressure * 0.95 : 0.32, + child: content, + ), ); } @@ -138,12 +156,19 @@ class _GlassDockState extends State with TickerProviderStateMixin { animation: Listenable.merge([_motion, _press]), builder: (context, _) { final press = _pressure; - final speed = (_drag != null ? _dragSpeed : _motion.velocity).abs(); - final stretch = (speed / 16).clamp(0.0, 0.12) + press * 0.18; + final speed = _reduceMotion + ? 0.0 + : (_drag != null + ? _dragSpeed + : _elasticMotion + ? _motion.velocity + : 0.0) + .abs(); + final stretch = (speed / 16).clamp(0.0, 0.14) + press * 0.30; final lensWidth = _travel * (1 + stretch); final lensHeight = (_height - _inset * 2) * - (1 + press * 0.19 - (speed / 16).clamp(0.0, 0.07)); + (1 + press * 0.31 - (speed / 16).clamp(0.0, 0.07)); return SizedBox( width: _width, height: _height, @@ -201,7 +226,7 @@ class _GlassDockState extends State with TickerProviderStateMixin { child: GestureDetector( key: index == widget.selectedIndex ? const Key('dock-cursor') : null, behavior: HitTestBehavior.opaque, - onTapDown: (_) => _pressTo(1), + onTapDown: index == widget.selectedIndex ? (_) => _pressTo(1) : null, onTapCancel: () { if (_drag == null) _pressTo(0); }, @@ -229,7 +254,8 @@ class _GlassDockState extends State with TickerProviderStateMixin { onHorizontalDragEnd: index == widget.selectedIndex ? (details) => _select( _position.round().clamp(0, 2), - details.velocity.pixelsPerSecond.dx / _travel, + elastic: true, + velocity: details.velocity.pixelsPerSecond.dx / _travel, ) : null, onHorizontalDragCancel: index == widget.selectedIndex @@ -238,7 +264,7 @@ class _GlassDockState extends State with TickerProviderStateMixin { final position = _position; setState(() => _drag = null); _motion.value = position; - _spring(widget.selectedIndex); + _moveTo(widget.selectedIndex, elastic: true); _pressTo(0); } : null, diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 1f1a470..10a4034 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,7 +1,7 @@ name: hepan_everyday description: RSchedule 研究生课表 publish_to: none -version: 0.3.2+6 +version: 0.4.0+7 environment: sdk: ^3.13.5 dependencies: diff --git a/mobile/test/app/school_term_test.dart b/mobile/test/app/school_term_test.dart new file mode 100644 index 0000000..b6be460 --- /dev/null +++ b/mobile/test/app/school_term_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/app/app_controller.dart'; +import 'package:hepan_everyday/domain/app_snapshot.dart'; +import 'package:hepan_everyday/domain/app_settings.dart'; +import 'package:hepan_everyday/domain/semester.dart'; +import 'package:hepan_everyday/domain/school_term.dart'; +import 'package:hepan_everyday/domain/exam_schedule.dart'; +import 'package:hepan_everyday/domain/course.dart'; +import 'package:hepan_everyday/data/json_schedule_import.dart'; + +import '../support/fakes.dart'; +import '../domain/schedule_engine_test.dart' show course; + +void main() { + test('term labels and both query URLs follow the school numbering', () { + expect(SchoolTerm(73).label, '2026-2027年秋'); + expect(SchoolTerm(72).label, '2025-2026年春'); + expect(SchoolTerm(71).label, '2025-2026年秋'); + expect(SchoolTerm(74).label, '2026-2027年春'); + expect(SchoolTerm(75).label, '2027-2028年秋'); + expect(SchoolTerm(73).timetableUri.path, '/pyxx/pygl/xskbcx/index/73'); + expect(SchoolTerm(72).examUri.queryParameters, {'xq': '72'}); + }); + test('selected school term survives settings copies and restart', () { + final saved = AppSnapshot(schoolTerm: SchoolTerm(72)); + final loaded = AppSnapshot.fromJson( + saved + .copyWith(settings: saved.settings.copyWith(reminderMinutes: 30)) + .toJson(), + ); + expect(loaded.schoolTerm, SchoolTerm(72)); + final legacy = saved.toJson()..remove('schoolTerm'); + expect(AppSnapshot.fromJson(legacy).schoolTerm, isNull); + expect(() => SchoolTerm(0), throwsArgumentError); + }); + test( + 'previous app saves recover the actual HTML term for subsequent refresh', + () { + final legacy = AppSnapshot( + examSchedule: ExamSchedule( + semesterLabel: '2026-2027秋', + termId: '73', + records: [], + ), + ).toJson()..remove('schoolTerm'); + expect(AppSnapshot.fromJson(legacy).schoolTerm, SchoolTerm(73)); + }, + ); + for (final exams in [false, true]) { + test( + 'successful new term ${exams ? 'exam' : 'course'} import isolates old dates and data', + () async { + final old = AppSnapshot( + schoolTerm: SchoolTerm(73), + courses: [course()], + examSchedule: ExamSchedule( + semesterLabel: 'old', + termId: '73', + records: [], + ), + settings: AppSettings( + semester: Semester(startDate: DateTime(2026, 9, 7), totalWeeks: 20), + ), + ); + final store = MemoryStore(old); + final c = AppController( + store, + FakeNotifications(), + FakeCalendar(), + () => DateTime(2026, 10, 13), + ); + await c.initialize(); + if (exams) { + await c.applyExams( + ExamSchedule( + semesterLabel: SchoolTerm(72).label, + termId: '72', + records: [], + ), + ); + expect(c.snapshot.courses, isEmpty); + } else { + await c.applyImport( + ImportDraft( + courses: [course()], + explicitlyEmpty: false, + source: CourseSource.file, + parserVersion: 'test', + schoolTerm: SchoolTerm(72), + ), + confirmedEmpty: false, + ); + expect(c.snapshot.examSchedule, isNull); + } + expect(c.lastError, isNull); + expect(c.snapshot.schoolTerm, SchoolTerm(72)); + expect(c.snapshot.settings.semester, isNull); + c.dispose(); + }, + ); + } + test('same-term refresh keeps date and exams; failed term change keeps all old data', () async { + final old = AppSnapshot( + schoolTerm: SchoolTerm(73), + courses: [course()], + examSchedule: ExamSchedule( + semesterLabel: 'old', + termId: '73', + records: [], + ), + settings: AppSettings( + semester: Semester(startDate: DateTime(2026, 9, 7), totalWeeks: 20), + ), + ); + final store = MemoryStore(old); + final c = AppController( + store, + FakeNotifications(), + FakeCalendar(), + () => DateTime(2026, 10, 13), + ); + await c.initialize(); + ImportDraft draft(int id) => ImportDraft( + courses: [course()], + explicitlyEmpty: false, + source: CourseSource.file, + parserVersion: 'test', + schoolTerm: SchoolTerm(id), + ); + await c.applyImport(draft(73), confirmedEmpty: false); + expect(c.snapshot.settings.semester, old.settings.semester); + expect(c.snapshot.examSchedule, old.examSchedule); + store.failSave = true; + await c.applyImport(draft(72), confirmedEmpty: false); + expect(c.lastError, isNotNull); + expect(c.snapshot.schoolTerm, SchoolTerm(73)); + expect(c.snapshot.settings.semester, old.settings.semester); + expect(c.snapshot.examSchedule, old.examSchedule); + c.dispose(); + }); +} diff --git a/mobile/test/data/json_schedule_import_test.dart b/mobile/test/data/json_schedule_import_test.dart index d701bb6..e88ee4f 100644 --- a/mobile/test/data/json_schedule_import_test.dart +++ b/mobile/test/data/json_schedule_import_test.dart @@ -3,6 +3,10 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:hepan_everyday/data/json_schedule_import.dart'; import 'package:hepan_everyday/domain/course.dart'; +import 'package:hepan_everyday/domain/app_snapshot.dart'; +import 'package:hepan_everyday/domain/school_term.dart'; + +import '../domain/schedule_engine_test.dart' show course; Map row({String id = 'a', int day = 1}) => { 'id': id, @@ -17,6 +21,27 @@ Map row({String id = 'a', int day = 1}) => { String input(List courses, {int version = 1, bool demo = false}) => jsonEncode({'schemaVersion': version, 'isDemo': demo, 'courses': courses}); void main() { + test('saved term metadata and legacy course IDs keep JSON imports in their actual term', () { + final saved = AppSnapshot( + schoolTerm: SchoolTerm(72), + courses: [course()], + ).toJson(); + expect( + JsonScheduleImport.parse(jsonEncode(saved)).schoolTerm, + SchoolTerm(72), + ); + saved.remove('schoolTerm'); + (saved['courses'] as List).first['id'] = 'uestc-71-101-1-1-2-example'; + expect( + JsonScheduleImport.parse(jsonEncode(saved)).schoolTerm, + SchoolTerm(71), + ); + saved['schoolTerm'] = {'id': 0}; + expect( + () => JsonScheduleImport.parse(jsonEncode(saved)), + throwsFormatException, + ); + }); test('file import normalizes weeks and cannot claim school provenance', () { final parsed = JsonScheduleImport.parse(input([row()])); expect(parsed.courses, hasLength(1)); diff --git a/mobile/test/school/login_flow_test.dart b/mobile/test/school/login_flow_test.dart index 820d7db..77c6577 100644 --- a/mobile/test/school/login_flow_test.dart +++ b/mobile/test/school/login_flow_test.dart @@ -2,6 +2,32 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hepan_everyday/features/school/school_login_flow.dart'; void main() { + test( + 'exam redirect compares selected xq rather than only the shared path', + () { + final flow = SchoolLoginFlow(); + final target = Uri.parse( + 'https://yjsjy.uestc.edu.cn/pyxx/pygl/kskcxx/index?xq=72', + ); + flow.begin(target); + expect( + flow.onLoaded( + target.replace(query: 'xq=72&view=student'), + authenticated: true, + ), + isNull, + ); + expect( + flow.onLoaded(target.replace(query: 'xq=73'), authenticated: true), + target, + ); + flow.begin(target); + expect( + flow.onLoaded(target.replace(query: ''), authenticated: true), + target, + ); + }, + ); test( 'repeated school login bounces have a bounded automatic redirect budget', () { diff --git a/mobile/test/school/school_page_probe_js_test.cjs b/mobile/test/school/school_page_probe_js_test.cjs index be9270d..af8dc80 100644 --- a/mobile/test/school/school_page_probe_js_test.cjs +++ b/mobile/test/school/school_page_probe_js_test.cjs @@ -11,8 +11,8 @@ const timetable = 'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1'; function term(id) { const result = { selectedIndex: 1, - options: [false, false].map(selected => ({ - selected, + options: [false, false].map((selected, index) => ({ + selected, value: String(73 - index), setAttribute() { this.selected = true; }, removeAttribute() { this.selected = false; }, })), @@ -56,6 +56,7 @@ assert.equal(probe(doc('https://yjsjy.uestc.edu.cn/pyxx/jzsso/login')).authentic const top = probe(doc(timetable, {table: true})); assert.equal(top.pages.length, 1); +assert.deepEqual(top.terms, [73, 72], 'bounded school term choices contain only dropdown IDs'); assert.match(top.pages[0].html, /value="1" selected="selected"/); assert.doesNotMatch(top.pages[0].html, /value="0" selected/); diff --git a/mobile/test/school/school_session_flow_test.dart b/mobile/test/school/school_session_flow_test.dart index 82b573a..f418a4b 100644 --- a/mobile/test/school/school_session_flow_test.dart +++ b/mobile/test/school/school_session_flow_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; 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/domain/school_term.dart'; import 'package:hepan_everyday/features/school/school_exam_adapter.dart'; import '../support/fake_school_web.dart'; @@ -26,7 +27,7 @@ void main() { } test('graduate logout function automatically opens timetable without an exit anchor', () async { - await session.open(); + await session.openTimetable(term: SchoolTerm(73)); platform.web.javascript = (script) async => script.contains("typeof window.logout") ? schoolPageEvidence(topUrl: platform.web.url!, authenticated: true) @@ -37,7 +38,7 @@ void main() { }); test('manual graduate page reads recognized embedded timetable instead of restarting login', () async { - await session.open(); + await session.openTimetable(term: SchoolTerm(73)); const home = 'https://yjsjy.uestc.edu.cn/pyxx/home/index'; platform.web.url = home; platform.web.javascript = (_) async => schoolPageEvidence( @@ -53,7 +54,7 @@ void main() { test( 'closed authentication screen ignores later page-finished callbacks', () async { - await session.open(); + await session.openTimetable(term: SchoolTerm(73)); session.setReadingActive(true); session.setReadingActive(false); platform.web.javascript = (_) async => @@ -68,7 +69,7 @@ void main() { test( 'asynchronous timetable DOM is imported without another navigation', () async { - await session.open(); + await session.openTimetable(term: SchoolTerm(73)); session.setReadingActive(true); var ready = false; platform.web.javascript = (_) async => schoolPageEvidence( @@ -88,7 +89,7 @@ void main() { ); test('stale script response cannot cancel polling on a newer page', () async { - await session.open(); + await session.openTimetable(term: SchoolTerm(73)); session.setReadingActive(true); final old = Completer(); platform.web.javascript = (_) => old.future; @@ -116,7 +117,7 @@ void main() { }); test('external document evidence cannot become a school import', () async { - await session.open(); + await session.openTimetable(term: SchoolTerm(73)); platform.web.url = SchoolSession.timetableUrl.toString(); for (final url in [ 'https://evil.test/pyxx/pygl/xskbcx/index/1', @@ -135,7 +136,7 @@ void main() { }); test('read-current recovery imports a frame and never reloads SSO', () async { - await session.open(); + await session.openTimetable(term: SchoolTerm(73)); const home = 'http://yjsjy.uestc.edu.cn/pyxx/home/index'; platform.web.url = home; platform.web.javascript = (_) async => schoolPageEvidence( @@ -152,7 +153,7 @@ void main() { test( 'authenticated graduate root dashboard automatically opens timetable', () async { - await session.open(); + await session.openTimetable(term: SchoolTerm(73)); const home = 'http://yjsjy.uestc.edu.cn/pyxx?targetUrl=-1'; final decision = await platform.delegate.navigation( const NavigationRequest(url: home, isMainFrame: true), @@ -169,7 +170,7 @@ void main() { test( 'obsolete automatic request failure preserves a newer pending import', () async { - await session.open(); + await session.openTimetable(term: SchoolTerm(73)); final oldRequest = Completer(); platform.web.request = (_) => oldRequest.future; platform.web.javascript = (_) async => @@ -190,7 +191,7 @@ void main() { ); test('actual session follows service login then reads exam HTML', () async { - await session.openExams(); + await session.openExams(term: SchoolTerm(73)); expect(platform.web.loads.single, SchoolSession.authUrl.toString()); navigate('https://idas.uestc.edu.cn/authserver/login?service=graduate'); await flushSchoolEvents(); @@ -216,7 +217,7 @@ void main() { test( 'obsolete main-frame error cannot interrupt a newer exam read', () async { - await session.openExams(); + await session.openExams(term: SchoolTerm(73)); platform.web.javascript = (_) async => schoolPageEvidence( topUrl: platform.web.url!, html: examHtml(), @@ -240,11 +241,11 @@ void main() { test( 'open waits for logout so old clearing cannot blank the new login', () async { - await session.open(); + await session.openTimetable(term: SchoolTerm(73)); platform.cookies.clearing = Completer(); final closing = session.logout(); await flushSchoolEvents(); - final reopening = session.openExams(); + final reopening = session.openExams(term: SchoolTerm(73)); await flushSchoolEvents(); platform.cookies.clearing!.complete(true); await closing; @@ -257,11 +258,11 @@ void main() { test( 'acknowledging an old saved draft preserves a newer pending import', () async { - await session.openExams(); + await session.openExams(term: SchoolTerm(73)); final old = SchoolExamAdapter.extract(examHtml()); session.pendingExams = old; final generation = session.pageGeneration; - await session.openExams(); + await session.openExams(term: SchoolTerm(73)); final newer = SchoolExamAdapter.extract(examHtml(room: '品学楼A101')); session.pendingExams = newer; expect( @@ -283,7 +284,7 @@ void main() { test( 'native logout failure is reported and subsequent login can recover', () async { - await session.open(); + await session.openTimetable(term: SchoolTerm(73)); session.connectionState = SchoolConnectionState.connected; platform.cookies.clearing = Completer(); final closing = session.logout(); @@ -294,12 +295,12 @@ void main() { expect(await result, isTrue); expect(session.connectionState, isNot(SchoolConnectionState.connected)); expect(session.message, contains('退出未完成')); - await session.openExams(); + await session.openExams(term: SchoolTerm(73)); expect(platform.web.loads.last, SchoolSession.authUrl.toString()); }, ); test('university HTTP identity redirect upgrades to HTTPS with service preserved', () async { - await session.open(); + await session.openTimetable(term: SchoolTerm(73)); 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( diff --git a/mobile/test/school/term_picker_widget_test.dart b/mobile/test/school/term_picker_widget_test.dart new file mode 100644 index 0000000..3b96294 --- /dev/null +++ b/mobile/test/school/term_picker_widget_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/app/app_controller.dart'; +import 'package:hepan_everyday/domain/app_snapshot.dart'; +import 'package:hepan_everyday/domain/school_term.dart'; +import 'package:hepan_everyday/features/school/school_auth_page.dart'; +import 'package:hepan_everyday/features/school/school_session.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; + +import '../support/fake_school_web.dart'; +import '../support/fakes.dart'; + +// Native WebView rendering is unavailable to widget tests. All session and +// authentication logic still uses the real controller through the platform. +class _WidgetSchoolPlatform extends FakeSchoolPlatform { + @override + PlatformWebViewWidget createPlatformWebViewWidget( + PlatformWebViewWidgetCreationParams params, + ) => _WebSurface(params); +} + +class _WebSurface extends PlatformWebViewWidget { + _WebSurface(super.params) : super.implementation(); + @override + Widget build(BuildContext context) => const SizedBox.expand(); +} + +void main() { + testWidgets( + 'picker waits for school login and selected row only imports on confirmation', + (tester) async { + final platform = _WidgetSchoolPlatform(); + WebViewPlatform.instance = platform; + final session = SchoolSession(); + final controller = AppController( + MemoryStore(), + FakeNotifications(), + FakeCalendar(), + () => DateTime(2026, 10, 13), + ); + await controller.initialize(); + await session.openExams(); + await tester.pumpWidget( + MaterialApp( + home: SchoolAuthPage(session: session, controller: controller), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('选择学校学期'), findsNothing); + const home = 'https://yjsjy.uestc.edu.cn/pyxx/home/index'; + platform.web.url = home; + platform.web.javascript = (_) async => + schoolPageEvidence(topUrl: home, authenticated: true); + platform.delegate.started(home); + platform.delegate.finished(home); + await tester.pumpAndSettle(); + expect(find.text('选择学校学期'), findsOneWidget); + await tester.tap(find.text('2025-2026年春')); + await tester.pump(); + expect(platform.web.loads.length, 1); + expect(controller.snapshot.schoolTerm, isNull); + await tester.tap(find.text('导入所选学期')); + await tester.pump(); + expect( + platform.web.loads.last, + 'https://yjsjy.uestc.edu.cn/pyxx/pygl/kskcxx/index?xq=72', + ); + expect(controller.snapshot.schoolTerm, isNull); + await tester.pumpWidget(const SizedBox()); + session.dispose(); + controller.dispose(); + }, + ); + + testWidgets('canceling a connected term change leaves saved term intact', ( + tester, + ) async { + final platform = _WidgetSchoolPlatform(); + WebViewPlatform.instance = platform; + final session = SchoolSession(); + final controller = AppController( + MemoryStore(AppSnapshot(schoolTerm: SchoolTerm(72))), + FakeNotifications(), + FakeCalendar(), + () => DateTime(2026, 10, 13), + ); + await controller.initialize(); + session.connectionState = SchoolConnectionState.connected; + await session.openTimetable(term: SchoolTerm(72), chooseTerm: true); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + SchoolAuthPage(session: session, controller: controller), + ), + ), + child: const Text('打开学校'), + ), + ), + ), + ); + await tester.tap(find.text('打开学校')); + await tester.pumpAndSettle(); + expect(find.text('选择学校学期'), findsOneWidget); + await tester.tap(find.text('2026-2027年秋')); + await tester.tap(find.text('取消')); + await tester.pumpAndSettle(); + expect(find.text('选择学校学期'), findsNothing); + expect(platform.web.loads, isEmpty); + expect(session.selectedTerm, SchoolTerm(72)); + expect(controller.snapshot.schoolTerm, SchoolTerm(72)); + await tester.pumpWidget(const SizedBox()); + session.dispose(); + controller.dispose(); + }); +} diff --git a/mobile/test/school/term_selection_test.dart b/mobile/test/school/term_selection_test.dart new file mode 100644 index 0000000..7b12a21 --- /dev/null +++ b/mobile/test/school/term_selection_test.dart @@ -0,0 +1,181 @@ +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_page_probe.dart'; +import 'package:hepan_everyday/domain/school_term.dart'; + +import 'dart:convert'; + +import '../support/fake_school_web.dart'; +import 'recognized_timetable_test.dart' show fixture; +import 'exam_adapter_test.dart' show examHtml; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test( + 'first online import waits for an explicit term even on a ready table', + () async { + final platform = FakeSchoolPlatform(); + WebViewPlatform.instance = platform; + final session = SchoolSession(); + addTearDown(session.dispose); + await session.open(); + const url = 'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/73'; + platform.web.url = url; + platform.web.javascript = (_) async => + schoolPageEvidence(topUrl: url, authenticated: true, html: fixture()); + platform.delegate.started(url); + platform.delegate.finished(url); + await flushSchoolEvents(); + expect(session.pendingDraft, isNull); + expect(session.finding, isFalse); + expect(session.needsTermSelection, isTrue); + }, + ); + + test('signed out change waits for authentication then explicit choice before requesting exams', () async { + final platform = FakeSchoolPlatform(); + WebViewPlatform.instance = platform; + final session = SchoolSession(); + addTearDown(session.dispose); + await session.openExams(term: SchoolTerm(73), chooseTerm: true); + expect(session.needsTermSelection, isFalse); + expect( + platform.web.loads.single, + 'https://yjsjy.uestc.edu.cn/pyxx/jzsso/login', + ); + const home = 'https://yjsjy.uestc.edu.cn/pyxx/home/index'; + platform.web.url = home; + platform.web.javascript = (_) async => + schoolPageEvidence(topUrl: platform.web.url!, authenticated: true); + platform.delegate.started(home); + platform.delegate.finished(home); + await flushSchoolEvents(); + expect(session.needsTermSelection, isTrue); + expect(platform.web.loads.length, 1); + await session.selectTerm(SchoolTerm(72)); + expect( + platform.web.loads.last, + 'https://yjsjy.uestc.edu.cn/pyxx/pygl/kskcxx/index?xq=72', + ); + expect(session.needsTermSelection, isFalse); + }); + + test('connected change prompts immediately without replacing imported data or navigating', () async { + final platform = FakeSchoolPlatform(); + WebViewPlatform.instance = platform; + final session = SchoolSession(); + addTearDown(session.dispose); + session.connectionState = SchoolConnectionState.connected; + await session.openTimetable(term: SchoolTerm(72), chooseTerm: true); + expect(session.needsTermSelection, isTrue); + expect(platform.web.loads, isEmpty); + expect(session.selectedTerm, SchoolTerm(72)); + expect(session.pendingDraft, isNull); + expect(session.termChoices.first.id, 73); + expect(session.termChoices.last.id, 1); + await session.selectTerm(SchoolTerm(71)); + expect( + platform.web.loads.single, + 'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/71', + ); + }); + + test('timetable selected DOM term must match request before an import can become pending', () async { + final platform = FakeSchoolPlatform(); + WebViewPlatform.instance = platform; + final session = SchoolSession(); + addTearDown(session.dispose); + session.connectionState = SchoolConnectionState.connected; + await session.openTimetable(term: SchoolTerm(72)); + platform.web.javascript = (_) async => + schoolPageEvidence(topUrl: platform.web.url!, html: fixture()); + platform.delegate.started(platform.web.url!); + platform.delegate.finished(platform.web.url!); + await flushSchoolEvents(); + expect(session.pendingDraft, isNull); + platform.web.javascript = (_) async => schoolPageEvidence( + topUrl: platform.web.url!, + html: fixture().replaceFirst('value="73"', 'value="72"'), + ); + await session.readCurrentPage(); + expect(session.pendingDraft?.schoolTerm, SchoolTerm(72)); + }); + + test( + 'exam selected DOM term must match requested query before saving', + () async { + final platform = FakeSchoolPlatform(); + WebViewPlatform.instance = platform; + final session = SchoolSession(); + addTearDown(session.dispose); + session.connectionState = SchoolConnectionState.connected; + await session.openExams(term: SchoolTerm(72)); + platform.web.javascript = (_) async => schoolPageEvidence( + topUrl: platform.web.url!, + html: examHtml(), + kind: 'exams', + ); + platform.delegate.started(platform.web.url!); + platform.delegate.finished(platform.web.url!); + await flushSchoolEvents(); + expect(session.pendingExams, isNull); + platform.web.javascript = (_) async => schoolPageEvidence( + topUrl: platform.web.url!, + html: examHtml().replaceFirst('value="73"', 'value="72"'), + kind: 'exams', + ); + await session.readCurrentPage(); + expect(session.pendingExams?.termId, '72'); + }, + ); + + test( + 're-authentication retains chosen term and unfinished picker intent', + () async { + final platform = FakeSchoolPlatform(); + WebViewPlatform.instance = platform; + final session = SchoolSession(); + addTearDown(session.dispose); + session.connectionState = SchoolConnectionState.connected; + await session.openExams(term: SchoolTerm(72), chooseTerm: true); + await session.openAuthentication(); + expect(session.needsTermSelection, isFalse); + expect(session.selectedTerm, SchoolTerm(72)); + const home = 'https://yjsjy.uestc.edu.cn/pyxx/home/index'; + platform.web.url = home; + platform.web.javascript = (_) async => + schoolPageEvidence(topUrl: home, authenticated: true); + platform.delegate.started(home); + platform.delegate.finished(home); + await flushSchoolEvents(); + expect(session.needsTermSelection, isTrue); + await session.selectTerm(SchoolTerm(71)); + await session.openAuthentication(); + expect(session.selectedTerm, SchoolTerm(71)); + expect(session.needsTermSelection, isFalse); + platform.web.url = home; + platform.delegate.started(home); + platform.delegate.finished(home); + await flushSchoolEvents(); + expect( + platform.web.loads.last, + 'https://yjsjy.uestc.edu.cn/pyxx/pygl/kskcxx/index?xq=71', + ); + }, + ); + + test('live dropdown choices are validated, deduplicated and sorted without private fields', () { + final uri = Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/home/index'); + final evidence = SchoolPageEvidence.decode( + jsonEncode({ + 'topUrl': uri.toString(), + 'authenticated': true, + 'pages': [], + 'terms': [72, 73, 72, 0, -1, 10000, 'private-value'], + }), + uri, + ); + expect(evidence.terms.map((term) => term.id), [73, 72]); + }); +} diff --git a/mobile/test/widgets/dock_motion_test.dart b/mobile/test/widgets/dock_motion_test.dart index 70f0f79..33ac9a9 100644 --- a/mobile/test/widgets/dock_motion_test.dart +++ b/mobile/test/widgets/dock_motion_test.dart @@ -2,12 +2,79 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:timezone/data/latest.dart' as tzdata; import 'package:hepan_everyday/widgets/glass_dock.dart'; +import 'package:hepan_everyday/widgets/glass_refraction.dart'; import 'package:hepan_everyday/app/hepan_app.dart'; import 'navigation_test.dart' show uiController; void main() { setUpAll(tzdata.initializeTimeZones); + testWidgets('inactive icon press leaves the cursor at its resting size', ( + tester, + ) async { + await tester.pumpWidget(_dock()); + await tester.pumpAndSettle(); + final lens = find.byType(GlassRefraction).last; + final resting = tester.getSize(lens); + final gesture = await tester.startGesture( + tester.getCenter(find.byIcon(Icons.assignment_rounded)), + ); + await tester.pump(const Duration(milliseconds: 110)); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.getSize(lens), resting); + await gesture.cancel(); + await tester.pumpAndSettle(); + }); + testWidgets('tap cursor travels monotonically with no size jitter', ( + tester, + ) async { + await tester.pumpWidget(_dock()); + await tester.pumpAndSettle(); + final lens = find.byType(GlassRefraction).last; + final resting = tester.getRect(lens); + final target = tester.getCenter(find.byIcon(Icons.assignment_rounded)).dx; + await tester.tap(find.byIcon(Icons.assignment_rounded)); + await tester.pump(); + var previous = resting.center.dx; + for (var frame = 0; frame < 50; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + final rect = tester.getRect(lens); + expect(rect.width, closeTo(resting.width, 0.05)); + expect(rect.height, closeTo(resting.height, 0.05)); + expect(rect.center.dx, greaterThanOrEqualTo(previous - 0.05)); + expect(rect.center.dx, lessThanOrEqualTo(target + 0.05)); + previous = rect.center.dx; + } + expect(previous, closeTo(target, 0.05)); + }); + testWidgets('base and selected cursor both refract the live backdrop', ( + tester, + ) async { + await tester.pumpWidget(_dock()); + await tester.pumpAndSettle(); + final glass = find.byType(GlassRefraction); + expect(glass, findsNWidgets(2)); + expect(tester.getSize(glass.first), const Size(264, 68)); + expect( + tester.widget(glass.first).strength, + greaterThan(0), + ); + }); + testWidgets('reduced motion snaps the cursor without oscillation', ( + tester, + ) async { + await tester.pumpWidget(_dock(reduceMotion: true)); + await tester.pumpAndSettle(); + final lens = find.byType(GlassRefraction).last; + final resting = tester.getSize(lens); + await tester.tap(find.byIcon(Icons.person_rounded)); + await tester.pump(); + final target = tester.getCenter(find.byIcon(Icons.person_rounded)).dx; + expect(tester.getCenter(lens).dx, closeTo(target, 0.05)); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.getCenter(lens).dx, closeTo(target, 0.05)); + expect(tester.getSize(lens), resting); + }); testWidgets('held cursor enlarges before dragging and returns on release', ( tester, ) async { @@ -32,8 +99,8 @@ void main() { await tester.pump(const Duration(milliseconds: 110)); await tester.pump(const Duration(milliseconds: 200)); final held = tester.getSize(lens); - expect(held.width, greaterThan(resting.width * 1.12)); - expect(held.height, greaterThan(resting.height * 1.12)); + expect(held.width, greaterThan(resting.width * 1.25)); + expect(held.height, greaterThan(resting.height * 1.25)); await gesture.cancel(); await tester.pumpAndSettle(); expect(tester.getSize(lens).width, closeTo(resting.width, 0.5)); @@ -76,3 +143,22 @@ void main() { }, ); } + +Widget _dock({bool reduceMotion = false}) { + var selected = 0; + return MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduceMotion), + child: StatefulBuilder( + builder: (context, setState) => Scaffold( + body: Center( + child: GlassDock( + selectedIndex: selected, + onSelected: (index) => setState(() => selected = index), + ), + ), + ), + ), + ), + ); +} diff --git a/mobile/test/widgets/glass_dock_test.dart b/mobile/test/widgets/glass_dock_test.dart index 5bf3197..db0e0be 100644 --- a/mobile/test/widgets/glass_dock_test.dart +++ b/mobile/test/widgets/glass_dock_test.dart @@ -16,7 +16,7 @@ void main() { await c.showDemo(); await tester.pumpWidget(HepanApp(controller: c)); await tester.pumpAndSettle(); - expect(find.text('RSchedule'), findsOneWidget); + expect(find.text('课表'), findsOneWidget); expect(tester.getRect(find.text('周日')).right, lessThanOrEqualTo(800)); await tester.drag(find.text('周三'), const Offset(-250, 0)); await tester.pumpAndSettle(); diff --git a/mobile/test/widgets/rschedule_navigation_test.dart b/mobile/test/widgets/rschedule_navigation_test.dart index a3ac3d8..fef12da 100644 --- a/mobile/test/widgets/rschedule_navigation_test.dart +++ b/mobile/test/widgets/rschedule_navigation_test.dart @@ -19,7 +19,7 @@ void main() { await c.showDemo(); await tester.pumpWidget(HepanApp(controller: c)); await tester.pumpAndSettle(); - expect(find.text('RSchedule'), findsOneWidget); + expect(find.text('课表'), findsOneWidget); expect(find.bySemanticsLabel('考试'), findsOneWidget); await tester.drag(find.text('周三'), const Offset(0, -200)); await tester.pumpAndSettle(); diff --git a/mobile/test/widgets/startup_test.dart b/mobile/test/widgets/startup_test.dart index 43d1de2..29c9ab7 100644 --- a/mobile/test/widgets/startup_test.dart +++ b/mobile/test/widgets/startup_test.dart @@ -7,6 +7,7 @@ import 'package:hepan_everyday/app/hepan_app.dart'; import 'package:hepan_everyday/domain/app_snapshot.dart'; import 'package:hepan_everyday/domain/app_settings.dart'; import 'package:hepan_everyday/domain/semester.dart'; +import 'package:hepan_everyday/widgets/glass_dock.dart'; import '../support/fakes.dart'; import '../domain/schedule_engine_test.dart' show course; @@ -38,7 +39,13 @@ void main() { buildApplication(controller, notificationSelection: selection), ); await tester.pumpAndSettle(); - expect(find.bySemanticsLabel('课表'), findsOneWidget); + expect( + find.descendant( + of: find.byType(GlassDock), + matching: find.bySemanticsLabel('课表'), + ), + findsOneWidget, + ); expect(find.bySemanticsLabel('个人设置'), findsOneWidget); await tester.tap(find.bySemanticsLabel('个人设置')); await tester.pumpAndSettle(); diff --git a/mobile/test/widgets/term_prompt_test.dart b/mobile/test/widgets/term_prompt_test.dart new file mode 100644 index 0000000..3a29f6d --- /dev/null +++ b/mobile/test/widgets/term_prompt_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:timezone/data/latest.dart' as tzdata; +import 'package:hepan_everyday/app/hepan_app.dart'; +import 'package:hepan_everyday/app/app_controller.dart'; +import 'package:hepan_everyday/domain/app_snapshot.dart'; +import 'package:hepan_everyday/domain/semester.dart'; +import 'package:hepan_everyday/features/timetable/timetable_page.dart'; + +import '../support/fakes.dart'; +import '../domain/schedule_engine_test.dart' show course; + +void main() { + setUpAll(tzdata.initializeTimeZones); + testWidgets('imported courses without start date show an actionable prompt', ( + tester, + ) async { + final c = AppController( + MemoryStore(AppSnapshot(courses: [course()])), + FakeNotifications(), + FakeCalendar(), + () => DateTime(2026, 10, 13), + ); + await c.initialize(); + await tester.pumpWidget(HepanApp(controller: c)); + await tester.pumpAndSettle(); + expect(find.text('课表已导入,请设置学期开始日'), findsOneWidget); + expect(find.text('1 条课程安排已保存'), findsOneWidget); + await tester.tap(find.text('设置学期开始日')); + await tester.pumpAndSettle(); + expect(find.text('课表设置'), findsOneWidget); + expect(find.text('学期开始日').hitTestable(), findsOneWidget); + await c.updateSettings( + (s) => s.copyWith( + semester: Semester(startDate: DateTime(2026, 10, 12), totalWeeks: 20), + ), + ); + Navigator.of(tester.element(find.text('课表设置'))).pop(); + await tester.pumpAndSettle(); + expect(find.text('课表已导入,请设置学期开始日'), findsNothing); + expect(find.text('周日'), findsOneWidget); + c.dispose(); + }); + testWidgets( + 'timetable refresh calls school sync and page titles are specific', + (tester) async { + final c = AppController( + MemoryStore(), + FakeNotifications(), + FakeCalendar(), + () => DateTime(2026, 10, 13), + ); + await c.initialize(); + var calls = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TimetablePage(controller: c, onSync: () => calls++), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('刷新课表')); + expect(calls, 1); + expect(find.text('课表'), findsOneWidget); + await tester.pumpWidget(HepanApp(controller: c)); + await tester.pumpAndSettle(); + await tester.tap(find.bySemanticsLabel('考试')); + await tester.pumpAndSettle(); + expect(find.text('考试'), findsOneWidget); + expect(find.text('考试安排'), findsOneWidget); + await tester.tap(find.bySemanticsLabel('个人设置')); + await tester.pumpAndSettle(); + expect(find.text('更新学期'), findsOneWidget); + expect(find.text('RSchedule'), findsWidgets); + c.dispose(); + }, + ); +}