import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:webview_flutter/webview_flutter.dart'; import '../../../data/json_schedule_import.dart'; import '../../../domain/exam_schedule.dart'; import '../../../domain/undergraduate_exam_query.dart'; import 'navigation_policy.dart'; import 'page_probe.dart'; import 'timetable_adapter.dart'; import 'exam_adapter.dart'; class UndergraduateSession extends ChangeNotifier { static final loginUri = Uri.parse('https://eams.uestc.edu.cn/eams/'); static const manualMessage = '请在学校网页打开课表并选择学期,然后点击导入当前课表。'; WebViewController? _web; WebViewController get web => _web ??= _createWeb(); bool _disposed = false, _active = false, _connected = false, _loading = false, _manualCapture = false, _failed = false, _complete = false; bool hasOpened = false, awaitingAuthentication = false, logoutFailed = false; String message = manualMessage; int _generation = 0, _document = 0, _index = 0; String? _probeKey; Uri? _expected; UndergraduateExamQuery? query; ImportDraft? pendingDraft; ExamSchedule? pendingExams; final _parts = []; Timer? _poll, _itemTimeout, _batchTimeout; Future? _logoutFuture; int get pageGeneration => _generation; bool get requestingExams => query != null; bool get canLogout => hasOpened || logoutFailed; bool get busy => _loading || _manualCapture || _itemTimeout != null; String get statusLabel => logoutFailed ? '退出未完成' : _failed ? '读取未完成' : _connected ? '已连接' : hasOpened ? '等待学校登录' : '未连接'; void _emit() { if (!_disposed) notifyListeners(); } bool _valid(int g) => !_disposed && _active && _generation == g; void _cancelTimers() { _poll?.cancel(); _itemTimeout?.cancel(); _batchTimeout?.cancel(); _poll = null; _itemTimeout = null; _batchTimeout = null; } void _reset() { _generation++; _cancelTimers(); _parts.clear(); _index = 0; _expected = null; pendingDraft = null; pendingExams = null; _complete = false; _failed = false; _manualCapture = false; _loading = false; _probeKey = null; } WebViewController _createWeb() => WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setNavigationDelegate( NavigationDelegate( onNavigationRequest: (request) async { final uri = Uri.tryParse(request.url); final upgrade = uri != null && request.isMainFrame ? UndergraduateNavigationPolicy.secureIdentityRedirect(uri) : null; if (upgrade != null) { try { await web.loadRequest(upgrade); } catch (_) { _failure('身份认证页面暂时无法打开'); } return NavigationDecision.prevent; } if (uri == null || !UndergraduateNavigationPolicy.canNavigate( uri, isMainFrame: request.isMainFrame, )) { if (request.isMainFrame) _failure('学校跳转未完成,请重新认证。'); return NavigationDecision.prevent; } if (request.isMainFrame && UndergraduateNavigationPolicy.isLogout(uri)) { invalidateAuthentication(); } return NavigationDecision.navigate; }, onPageStarted: (_) { _document++; _poll?.cancel(); _probeKey = null; pendingDraft = null; if (!requestingExams) _generation++; if (_active) { _loading = true; _emit(); } }, onPageFinished: _loaded, onWebResourceError: (error) async { if (error.isForMainFrame != true || error.url == null) return; final g = _generation, d = _document, current = await web.currentUrl(); if (_valid(g) && d == _document && error.url == current) { _failure('学校页面加载未完成,请检查网络后重试。'); } }, onHttpError: (error) async { final g = _generation, d = _document, current = await web.currentUrl(); if (_valid(g) && d == _document && error.request?.uri.toString() == current) { _failure('学校请求未完成,请稍后重试。'); } }, ), ); Future openTimetable() async { await _logoutFuture; if (_disposed) return; _reset(); query = null; _active = true; hasOpened = true; awaitingAuthentication = !_connected; message = manualMessage; await _navigate(loginUri); } Future openExams(UndergraduateExamQuery value) async { await _logoutFuture; if (_disposed) return; _reset(); query = value; _active = true; hasOpened = true; awaitingAuthentication = !_connected; message = '请在学校网页完成登录,随后自动查找考试。'; if (_connected) { await _requestExam(_generation); } else { await _navigate(loginUri); } } Future _navigate(Uri uri) async { final g = _generation; _loading = true; _emit(); try { await web.loadRequest(uri); } catch (_) { if (_valid(g)) _failure('学校页面打开未完成,请重试。'); } } void setReadingActive(bool active) { if (_disposed) return; if (!active) { _active = false; _reset(); return; } _active = true; if (hasOpened && !_loading && !_complete && !_failed) { unawaited( web.currentUrl().then((url) { if (url != null && _active) _loaded(url); }), ); } } void _failure(String text) { if (_disposed) return; _reset(); _failed = true; awaitingAuthentication = false; message = '读取未完成:$text'; _emit(); } void _waitForLogin() { _cancelTimers(); _parts.clear(); _index = 0; _expected = null; _connected = false; awaitingAuthentication = true; message = requestingExams ? '请在学校网页完成登录和验证码,随后重新读取全部考试。' : manualMessage; _emit(); } void _schedulePoll(int g, int document, String url) { _poll?.cancel(); _poll = Timer(const Duration(milliseconds: 450), () { if (_valid(g) && _document == document && !_failed && !_complete) { unawaited(_loaded(url)); } }); } Future _evidence(Uri uri, bool capture) async => UndergraduatePageEvidence.decode( await web.runJavaScriptReturningResult( capture ? undergraduateCaptureScript : undergraduateStatusProbeScript, ), uri, ); Future _loaded(String url) async { final g = _generation, d = _document, key = '$_generation:$_document'; if (!_valid(g) || _failed || _complete || _probeKey == key) return; _probeKey = key; try { final current = await web.currentUrl(); if (!_valid(g) || d != _document || current != url) return; final uri = Uri.tryParse(url); if (uri == null || !UndergraduateNavigationPolicy.canNavigate(uri, isMainFrame: true)) { return; } final status = await _evidence(uri, false); if (!_valid(g) || d != _document) return; _loading = false; if (!status.authenticated) { _waitForLogin(); _schedulePoll(g, d, url); return; } _connected = true; awaitingAuthentication = false; if (!requestingExams) { message = manualMessage; _emit(); return; } if (_expected == null || uri.toString() != _expected.toString()) { await _requestExam(g); return; } final evidence = await _evidence(uri, true); if (!_valid(g) || d != _document) return; final pages = evidence.pages.where((p) => p.kind == 'exams').toList(); if (pages.isEmpty) { _schedulePoll(g, d, url); return; } if (pages.length != 1) throw const FormatException('排考结果页面不明确'); final part = UndergraduateExamAdapter.extract( pages.single.html, expectedTerm: query!.term, expectedType: query!.orderedTypes[_index], ); if (!_valid(g) || d != _document) return; _parts.add(part); _index++; _itemTimeout?.cancel(); _itemTimeout = null; if (_index == query!.orderedTypes.length) { pendingExams = UndergraduateExamAdapter.merge(query!, _parts); _complete = true; _cancelTimers(); message = '考试已全部读取,正在保存…'; _emit(); } else { await _requestExam(g); } } catch (error) { if (_valid(g) && d == _document) _failure('$error'); } finally { if (_probeKey == key) _probeKey = null; } } Future _requestExam(int g) async { if (!_valid(g) || query == null || _complete) return; final type = query!.orderedTypes[_index]; _expected = query!.uriFor(type); _loading = true; message = '正在读取${type.label}(${_index + 1}/${query!.types.length})'; _emit(); _itemTimeout?.cancel(); _itemTimeout = Timer(const Duration(seconds: 45), () { if (_valid(g)) _failure('${type.label}读取超时,请重试。'); }); _batchTimeout ??= Timer(const Duration(seconds: 120), () { if (_valid(g)) _failure('考试读取超时,请重试。'); }); await _navigate(_expected!); } Future readCurrentTimetable() async { if (requestingExams || _manualCapture || !_active) return; final g = _generation, d = _document; _manualCapture = true; _emit(); try { final current = Uri.parse(await web.currentUrl() ?? ''); final evidence = await _evidence(current, true); if (!_valid(g) || d != _document) return; final pages = evidence.pages.where((p) => p.kind == 'timetable').toList(); if (pages.length != 1) { throw const FormatException('请先在学校网页打开完整课表,再点击导入。'); } final draft = UndergraduateTimetableAdapter.extract( pages.single.html, pages.single.url, ); if (!_valid(g) || d != _document) return; pendingDraft = draft; _connected = true; awaitingAuthentication = false; message = '课表已识别,正在确认导入…'; _emit(); } catch (error) { if (_valid(g) && d == _document) { message = '导入未完成:$error'; _emit(); } } finally { if (_valid(g)) { _manualCapture = false; _emit(); } } } bool saved({required int generation}) { if (!_valid(generation) || pendingDraft == null && pendingExams == null) { return false; } pendingDraft = null; pendingExams = null; message = '已导入'; _emit(); return true; } Future reauthenticate() async { await _logoutFuture; if (_disposed) return; _reset(); _active = true; _connected = false; awaitingAuthentication = true; hasOpened = true; message = '请在学校网页重新登录。'; await _navigate(loginUri); } void invalidateAuthentication() { _reset(); _active = false; hasOpened = false; _connected = false; awaitingAuthentication = false; message = '学校登录已退出'; _emit(); } Future logout() => _logoutFuture ??= _logout().whenComplete(() => _logoutFuture = null); Future _logout() async { final opened = hasOpened; _active = false; invalidateAuthentication(); if (!opened && !logoutFailed) return; try { await WebViewCookieManager().clearCookies(); if (_web != null) { await _web!.clearCache(); await _web!.clearLocalStorage(); await _web!.loadHtmlString(''); } hasOpened = false; logoutFailed = false; query = null; } catch (_) { logoutFailed = true; message = '退出未完成,请重试。'; } _emit(); } @override void dispose() { _disposed = true; _active = false; _generation++; _cancelTimers(); super.dispose(); } }