feat: add undergraduate import and exam selection screens

This commit is contained in:
Codex
2026-10-15 00:11:50 +08:00
parent 68fe71e29a
commit 7939bd4c3c
7 changed files with 584 additions and 24 deletions
+28 -24
View File
@@ -134,7 +134,9 @@ class AppController extends ChangeNotifier {
Future<void> 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<void> 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<void> 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) ||
@@ -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<ImportPreview> createState() => _ImportPreviewState();
}
@@ -41,6 +43,16 @@ class _ImportPreviewState extends State<ImportPreview> {
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<ImportPreview> {
await widget.controller.applyImport(
draft,
confirmedEmpty: _clearConfirmed,
canApply: widget.canApply,
);
if (!context.mounted) return;
if (widget.controller.lastError == null) {
@@ -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<UndergraduateAuthPage> createState() => _UndergraduateAuthPageState();
}
class _UndergraduateAuthPageState extends State<UndergraduateAuthPage> {
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<void> _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<bool>(
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<void> _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('导入当前课表'),
),
),
),
),
],
),
);
}
}
@@ -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<UndergraduateExamQuery?> showUndergraduateExamQueryPicker(
BuildContext context, {
UndergraduateExamQuery? initial,
required DateTime now,
}) => showDialog<UndergraduateExamQuery>(
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<UndergraduateExamType> _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<SchoolTerm>(
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('开始查询'),
),
],
);
}