diff --git a/mobile/lib/app/app_controller.dart b/mobile/lib/app/app_controller.dart index e0a01b6..73ed250 100644 --- a/mobile/lib/app/app_controller.dart +++ b/mobile/lib/app/app_controller.dart @@ -134,7 +134,9 @@ class AppController extends ChangeNotifier { Future applyImport( ImportDraft draft, { required bool confirmedEmpty, + bool Function()? canApply, }) => _run(() async { + if (canApply != null && !canApply()) throw StateError('学校页面已变化,请重新导入。'); if (draft.courses.isEmpty && (!draft.explicitlyEmpty || !confirmedEmpty)) { throw const FormatException('未检测到课程,已有课表保持不变'); } @@ -202,30 +204,32 @@ class AppController extends ChangeNotifier { } await _cleanupCalendar(); }); - Future applyExams(ExamSchedule value) => _run(() async { - final validated = ExamSchedule.fromJson(value.toJson()); - if (validated.school == SupportedSchool.uestcUndergraduate) { - await _save(snapshot.copyWith(examSchedule: validated)); - return; - } - 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(); - } - }); + Future applyExams(ExamSchedule value, {bool Function()? canApply}) => + _run(() async { + if (canApply != null && !canApply()) throw StateError('学校查询已取消,请重新读取。'); + final validated = ExamSchedule.fromJson(value.toJson()); + if (validated.school == SupportedSchool.uestcUndergraduate) { + await _save(snapshot.copyWith(examSchedule: validated)); + return; + } + 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) || diff --git a/mobile/lib/features/profile/import_preview.dart b/mobile/lib/features/profile/import_preview.dart index 3b376eb..6c917e9 100644 --- a/mobile/lib/features/profile/import_preview.dart +++ b/mobile/lib/features/profile/import_preview.dart @@ -10,9 +10,11 @@ class ImportPreview extends StatefulWidget { super.key, required this.draft, required this.controller, + this.canApply, }); final ImportDraft draft; final AppController controller; + final bool Function()? canApply; @override State createState() => _ImportPreviewState(); } @@ -41,6 +43,16 @@ class _ImportPreviewState extends State { style: const TextStyle(color: river), ), if (draft.semesterLabel != null) Text('学期:${draft.semesterLabel}'), + if (widget.controller.snapshot.courses.isNotEmpty && + widget.controller.snapshot.schoolTerm != draft.schoolTerm && + draft.schoolTerm != null) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + '将替换当前课表;导入新学校或新学期后,请重新设置学期开始日。', + style: TextStyle(fontSize: 12, height: 1.6), + ), + ), if (draft.schoolTerm != null) const Padding( padding: EdgeInsets.only(top: 8), @@ -106,6 +118,7 @@ class _ImportPreviewState extends State { await widget.controller.applyImport( draft, confirmedEmpty: _clearConfirmed, + canApply: widget.canApply, ); if (!context.mounted) return; if (widget.controller.lastError == null) { diff --git a/mobile/lib/features/school/undergraduate/auth_page.dart b/mobile/lib/features/school/undergraduate/auth_page.dart new file mode 100644 index 0000000..a1c67f0 --- /dev/null +++ b/mobile/lib/features/school/undergraduate/auth_page.dart @@ -0,0 +1,227 @@ +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +import '../../../app/app_controller.dart'; +import '../../../data/json_schedule_import.dart'; +import '../../../domain/exam_schedule.dart'; +import '../../profile/import_preview.dart'; +import 'session.dart'; +import 'exam_query_picker.dart'; + +class UndergraduateAuthPage extends StatefulWidget { + const UndergraduateAuthPage({ + super.key, + required this.session, + required this.controller, + }); + final UndergraduateSession session; + final AppController controller; + @override + State createState() => _UndergraduateAuthPageState(); +} + +class _UndergraduateAuthPageState extends State { + bool _saving = false; + Object? _handled; + String? _saveError; + @override + void initState() { + super.initState(); + widget.session.addListener(_changed); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + widget.session.setReadingActive(true); + _changed(); + } + }); + } + + void _changed() { + if (!mounted) return; + setState(() {}); + final s = widget.session, result = s.pendingDraft ?? s.pendingExams; + if (_saving || result == null || identical(_handled, result)) return; + _handled = result; + _saving = true; + final generation = s.pageGeneration; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + await _save(s.pendingDraft, s.pendingExams, generation); + }); + } + + Future _save( + ImportDraft? draft, + ExamSchedule? exams, + int generation, + ) async { + final s = widget.session, + controller = widget.controller, + route = ModalRoute.of(context); + bool current() => + mounted && + route?.isActive == true && + generation == s.pageGeneration && + identical(draft, s.pendingDraft) && + identical(exams, s.pendingExams); + try { + if (!current()) return; + var saved = false; + if (draft != null) { + final replacement = + controller.snapshot.courses.isNotEmpty && + controller.snapshot.schoolTerm != draft.schoolTerm; + if (replacement || draft.courses.isEmpty) { + final confirmed = await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (_) => ImportPreview( + draft: draft, + controller: controller, + canApply: current, + ), + ); + if (!current()) return; + saved = confirmed == true; + } else { + await controller.applyImport( + draft, + confirmedEmpty: false, + canApply: current, + ); + saved = controller.lastError == null; + } + } else if (exams != null) { + await controller.applyExams(exams, canApply: current); + saved = controller.lastError == null; + } + if (!current()) return; + if (saved && s.saved(generation: generation)) { + final messenger = ScaffoldMessenger.of(context); + if (route?.isCurrent == true) Navigator.pop(context); + messenger.showSnackBar( + SnackBar( + content: Text(exams == null ? '课表已导入,请核对学期开始日。' : '考试信息已更新'), + ), + ); + } else if (controller.lastError != null) { + _saveError = controller.lastError; + } + } finally { + _saving = false; + if (mounted) _changed(); + } + } + + @override + void dispose() { + widget.session.removeListener(_changed); + widget.session.setReadingActive(false); + super.dispose(); + } + + Future _changeQuery() async { + final value = await showUndergraduateExamQueryPicker( + context, + initial: widget.session.query, + now: widget.controller.clock(), + ); + if (!mounted || value == null) return; + _handled = null; + _saveError = null; + await widget.session.openExams(value); + } + + @override + Widget build(BuildContext context) { + final s = widget.session; + return Scaffold( + appBar: AppBar( + title: Text(s.requestingExams ? '本科考试查询' : '本科课表导入'), + actions: [ + if (s.requestingExams) + IconButton( + tooltip: '查询选项', + onPressed: _saving ? null : _changeQuery, + icon: const Icon(Icons.tune_rounded), + ), + IconButton( + tooltip: '重新认证', + onPressed: _saving + ? null + : () { + _handled = null; + _saveError = null; + s.reauthenticate(); + }, + icon: const Icon(Icons.refresh_rounded), + ), + ], + ), + body: Column( + children: [ + if (s.busy || _saving) const LinearProgressIndicator(minHeight: 2), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + color: const Color(0xFFEFF6FF), + child: Text( + s.message, + style: const TextStyle(fontSize: 12, height: 1.5), + ), + ), + if (s.requestingExams && s.query != null) + Padding( + padding: const EdgeInsets.all(8), + child: Text( + '${s.query!.term.label} · ${s.query!.orderedTypes.map((t) => t.label).join('、')}', + style: const TextStyle(fontSize: 12), + ), + ), + if (_saveError != null) + Padding( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + Text(_saveError!, style: const TextStyle(fontSize: 12)), + TextButton( + onPressed: _saving + ? null + : () { + _saveError = null; + _handled = null; + _changed(); + }, + child: const Text('重试保存'), + ), + ], + ), + ), + Expanded(child: WebViewWidget(controller: s.web)), + if (!s.requestingExams) + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 10), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _saving || s.busy + ? null + : () { + _handled = null; + _saveError = null; + s.readCurrentTimetable(); + }, + icon: const Icon(Icons.download_rounded), + label: const Text('导入当前课表'), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/school/undergraduate/exam_query_picker.dart b/mobile/lib/features/school/undergraduate/exam_query_picker.dart new file mode 100644 index 0000000..2e50738 --- /dev/null +++ b/mobile/lib/features/school/undergraduate/exam_query_picker.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +import '../../../domain/school_identity.dart'; +import '../../../domain/school_term.dart'; +import '../../../domain/undergraduate_exam_query.dart'; + +Future showUndergraduateExamQueryPicker( + BuildContext context, { + UndergraduateExamQuery? initial, + required DateTime now, +}) => showDialog( + context: context, + builder: (_) => _ExamPicker(initial: initial, now: now), +); + +class _ExamPicker extends StatefulWidget { + const _ExamPicker({this.initial, required this.now}); + final UndergraduateExamQuery? initial; + final DateTime now; + @override + State<_ExamPicker> createState() => _ExamPickerState(); +} + +class _ExamPickerState extends State<_ExamPicker> { + late SchoolTerm _term = + widget.initial?.term ?? + SchoolTerm(523, school: SupportedSchool.uestcUndergraduate); + late final Set _types = { + ...?widget.initial?.types, + if (widget.initial == null) UndergraduateExamType.finalExam, + }; + @override + Widget build(BuildContext context) => AlertDialog( + title: const Text('本科考试查询'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('选择一个学期和需要查询的考试类型'), + const SizedBox(height: 14), + DropdownButton( + isExpanded: true, + value: _term, + items: SchoolTerm.undergraduateChoices(widget.now, saved: _term) + .map((t) => DropdownMenuItem(value: t, child: Text(t.label))) + .toList(), + onChanged: (t) { + if (t != null) setState(() => _term = t); + }, + ), + for (final type in UndergraduateExamType.values) + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: Text(type.label), + value: _types.contains(type), + onChanged: (v) => setState(() { + if (v == true) { + _types.add(type); + } else { + _types.remove(type); + } + }), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + FilledButton( + onPressed: _types.isEmpty + ? null + : () => Navigator.pop( + context, + UndergraduateExamQuery(term: _term, types: _types), + ), + child: const Text('开始查询'), + ), + ], + ); +} diff --git a/mobile/test/support/fake_school_web.dart b/mobile/test/support/fake_school_web.dart index dbad84e..994a46b 100644 --- a/mobile/test/support/fake_school_web.dart +++ b/mobile/test/support/fake_school_web.dart @@ -1,9 +1,16 @@ import 'dart:async'; + +import 'package:flutter/widgets.dart'; + import 'dart:convert'; import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; class FakeSchoolPlatform extends WebViewPlatform { + @override + PlatformWebViewWidget createPlatformWebViewWidget( + PlatformWebViewWidgetCreationParams params, + ) => FakeSchoolWidget(params); int controllerCreations = 0; late FakeSchoolWeb web; late FakeSchoolDelegate delegate; @@ -26,6 +33,13 @@ class FakeSchoolPlatform extends WebViewPlatform { ) => cookies; } +class FakeSchoolWidget extends PlatformWebViewWidget { + FakeSchoolWidget(super.params) : super.implementation(); + @override + Widget build(BuildContext context) => + const SizedBox.expand(key: Key('fake-school-browser')); +} + class FakeSchoolWeb extends PlatformWebViewController { FakeSchoolWeb(super.params) : super.implementation(); final loads = []; diff --git a/mobile/test/widgets/undergraduate_auth_test.dart b/mobile/test/widgets/undergraduate_auth_test.dart new file mode 100644 index 0000000..09ce202 --- /dev/null +++ b/mobile/test/widgets/undergraduate_auth_test.dart @@ -0,0 +1,128 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart'; +import 'package:hepan_everyday/features/school/undergraduate/auth_page.dart'; +import 'package:hepan_everyday/features/school/undergraduate/session.dart'; +import 'package:hepan_everyday/features/school/undergraduate/page_probe.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 '../support/fake_school_web.dart'; +import '../support/fakes.dart'; +import '../school/undergraduate_session_test.dart' show underEvidence; +import '../domain/schedule_engine_test.dart' show course; + +void main() { + late FakeSchoolPlatform platform; + late UndergraduateSession session; + late AppController controller; + late MemoryStore store; + setUp(() { + platform = FakeSchoolPlatform(); + WebViewPlatform.instance = platform; + session = UndergraduateSession(); + store = MemoryStore(); + }); + tearDown(() { + session.dispose(); + controller.dispose(); + }); + Future open(WidgetTester tester) async { + controller = AppController( + store, + FakeNotifications(), + FakeCalendar(), + () => DateTime.utc(2026, 10, 14), + ); + await controller.initialize(reconcileServices: false); + await session.openTimetable(); + platform.web.javascript = (s) async => underEvidence( + platform.web.url!, + html: s == undergraduateCaptureScript + ? File('test/fixtures/school/undergraduate-timetable.html') + .readAsStringSync() + : null, + ); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => UndergraduateAuthPage( + session: session, + controller: controller, + ), + ), + ), + child: const Text('打开'), + ), + ), + ), + ), + ); + await tester.tap(find.text('打开')); + await tester.pumpAndSettle(); + } + + testWidgets('manual importer waits for the import button and then saves', ( + tester, + ) async { + await open(tester); + expect(find.text('导入当前课表'), findsOneWidget); + expect(session.pendingDraft, isNull); + expect(controller.snapshot.courses, isEmpty); + await tester.tap(find.text('导入当前课表')); + await tester.pumpAndSettle(); + expect(controller.snapshot.courses, hasLength(18)); + expect(find.text('打开'), findsOneWidget); + }); + testWidgets('cross school replacement can be cancelled without saving', ( + tester, + ) async { + store.value = AppSnapshot(courses: [course()], schoolTerm: SchoolTerm(73)); + await open(tester); + final previous = controller.snapshot.toJson(); + await tester.tap(find.text('导入当前课表')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.text('确认你的课表'), findsOneWidget); + await tester.ensureVisible(find.text('取消').last); + await tester.pump(); + await tester.tap(find.text('取消').last); + await tester.pumpAndSettle(); + expect(controller.snapshot.toJson(), previous); + expect(find.text('导入当前课表'), findsOneWidget); + }); + testWidgets('failed saving stays on the browser and can be retried', ( + tester, + ) async { + await open(tester); + store.failSave = true; + await tester.tap(find.text('导入当前课表')); + await tester.pumpAndSettle(); + expect(controller.snapshot.courses, isEmpty); + expect(find.text('重试保存'), findsOneWidget); + store.failSave = false; + await tester.tap(find.text('重试保存')); + await tester.pumpAndSettle(); + expect(controller.snapshot.courses, hasLength(18)); + }); + testWidgets('an obsolete deferred save does not strand a newer import', ( + tester, + ) async { + await open(tester); + await tester.tap(find.text('导入当前课表')); + await session.openTimetable(); + await session.readCurrentTimetable(); + platform.delegate.finished(platform.web.url!); + await tester.pumpAndSettle(); + expect(controller.snapshot.courses, hasLength(18)); + }); +} diff --git a/mobile/test/widgets/undergraduate_exam_picker_test.dart b/mobile/test/widgets/undergraduate_exam_picker_test.dart new file mode 100644 index 0000000..4aadd98 --- /dev/null +++ b/mobile/test/widgets/undergraduate_exam_picker_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/features/school/undergraduate/exam_query_picker.dart'; +import 'package:hepan_everyday/domain/undergraduate_exam_query.dart'; +import 'package:hepan_everyday/domain/school_term.dart'; +import 'package:hepan_everyday/domain/school_identity.dart'; + +void main() { + testWidgets('exam picker keeps one semester and requires at least one type', ( + tester, + ) async { + UndergraduateExamQuery? result; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () async { + result = await showUndergraduateExamQueryPicker( + context, + now: DateTime(2026, 10), + ); + }, + child: const Text('选择'), + ), + ), + ), + ), + ); + await tester.tap(find.text('选择')); + await tester.pumpAndSettle(); + expect(find.text('2026-2027第一学期'), findsOneWidget); + await tester.tap(find.text('期末考试')); + await tester.pump(); + expect( + tester + .widget(find.widgetWithText(FilledButton, '开始查询')) + .onPressed, + isNull, + ); + await tester.tap(find.text('期中考试')); + await tester.tap(find.text('补考/缓考')); + await tester.pump(); + await tester.tap(find.text('开始查询')); + await tester.pumpAndSettle(); + expect(result!.term.id, 523); + expect(result!.types, { + UndergraduateExamType.midterm, + UndergraduateExamType.makeup, + }); + }); + testWidgets('saved term and exam types persist and cancel returns null', ( + tester, + ) async { + final initial = UndergraduateExamQuery( + term: SchoolTerm(503, school: SupportedSchool.uestcUndergraduate), + types: {UndergraduateExamType.midterm}, + ); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showUndergraduateExamQueryPicker( + context, + initial: initial, + now: DateTime(2026, 10), + ), + child: const Text('选择'), + ), + ), + ), + ), + ); + await tester.tap(find.text('选择')); + await tester.pumpAndSettle(); + expect(find.text('2025-2026第二学期'), findsOneWidget); + expect( + tester + .widget( + find.widgetWithText(CheckboxListTile, '期中考试'), + ) + .value, + isTrue, + ); + await tester.tap(find.text('取消')); + await tester.pumpAndSettle(); + expect(find.text('开始查询'), findsNothing); + }); +}