feat: select school terms and smooth refractive Dock motion

This commit is contained in:
Codex
2026-10-13 19:08:36 +08:00
parent 3e2d8864af
commit 50fcc70e8e
33 changed files with 1245 additions and 94 deletions
+114 -17
View File
@@ -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<SchoolTerm> _liveTerms = const [];
bool get needsTermSelection =>
hasOpened &&
connectionState == SchoolConnectionState.connected &&
_termSelectionRequested &&
_termSelectionReady;
List<SchoolTerm> 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<void> open() => _open(exams: false);
Future<void> openTimetable() => _open(exams: false);
Future<void> openExams() => _open(exams: true);
Future<void> openAuthentication() =>
_open(exams: requestingExams, forceAuth: true);
Future<void> _open({required bool exams, bool forceAuth = false}) async {
Future<void> openTimetable({SchoolTerm? term, bool chooseTerm = false}) =>
_open(exams: false, term: term, chooseTerm: chooseTerm);
Future<void> openExams({SchoolTerm? term, bool chooseTerm = false}) =>
_open(exams: true, term: term, chooseTerm: chooseTerm);
Future<void> openAuthentication() => _open(
exams: requestingExams,
term: selectedTerm,
chooseTerm: _termSelectionRequested,
forceAuth: true,
);
Future<void> _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<void> 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<ImportDraft> 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<void> readCurrentPage() async {
if (needsTermSelection) return;
_poll?.cancel();
_timeout?.cancel();
_pageGeneration++;