feat: deliver Riverside Schedule seven-day grid and school HTML import

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