feat: integrate undergraduate school workflows and period twelve

This commit is contained in:
Codex
2026-10-15 00:28:04 +08:00
parent 7939bd4c3c
commit b28f333720
15 changed files with 573 additions and 26 deletions
+1
View File
@@ -53,6 +53,7 @@ class AppController extends ChangeNotifier {
Future<void> _save(AppSnapshot next) async {
_checkStorage();
AppSnapshot.validateCourseSource(next.courses, next.schoolTerm);
await store.save(next);
snapshot = next;
lastError = null;
+100 -12
View File
@@ -15,6 +15,9 @@ import '../features/timetable/timetable_page.dart';
import '../features/profile/profile_page.dart';
import '../features/school/school_session.dart';
import '../features/school/school_selection_page.dart';
import '../features/school/undergraduate/session.dart';
import '../features/school/undergraduate/exam_query_picker.dart';
import '../domain/undergraduate_exam_query.dart';
class HepanApp extends StatelessWidget {
const HepanApp({
@@ -22,12 +25,16 @@ class HepanApp extends StatelessWidget {
required this.controller,
this.schoolBuilder,
this.school,
this.undergraduate,
this.undergraduateBuilder,
this.notificationSelection,
this.feedbackSender,
});
final AppController controller;
final Widget Function()? schoolBuilder;
final SchoolSession? school;
final UndergraduateSession? undergraduate;
final Widget Function()? undergraduateBuilder;
final ValueNotifier<String?>? notificationSelection;
final FeedbackSender? feedbackSender;
@override
@@ -42,6 +49,8 @@ class HepanApp extends StatelessWidget {
controller: controller,
schoolBuilder: schoolBuilder,
school: school,
undergraduate: undergraduate,
undergraduateBuilder: undergraduateBuilder,
notificationSelection: notificationSelection,
feedbackSender: feedbackSender ?? configuredFeedbackSender(),
),
@@ -53,12 +62,16 @@ class _Shell extends StatefulWidget {
required this.controller,
this.schoolBuilder,
this.school,
this.undergraduate,
this.undergraduateBuilder,
this.notificationSelection,
required this.feedbackSender,
});
final AppController controller;
final Widget Function()? schoolBuilder;
final SchoolSession? school;
final UndergraduateSession? undergraduate;
final Widget Function()? undergraduateBuilder;
final ValueNotifier<String?>? notificationSelection;
final FeedbackSender feedbackSender;
@override
@@ -68,11 +81,13 @@ class _Shell extends StatefulWidget {
class _ShellState extends State<_Shell> with WidgetsBindingObserver {
int _tab = 0;
bool _openingSchool = false;
SupportedSchool? _selectedSchool;
// Earlier imports all belong to UESTC graduate; reuse their existing term
// and session without requiring students to choose again on every refresh.
SupportedSchool? get _knownSchool =>
_selectedSchool ??
widget.controller.snapshot.selectedSchool ??
(widget.undergraduate?.canLogout == true
? SupportedSchool.uestcUndergraduate
: null) ??
(widget.controller.snapshot.schoolTerm != null ||
widget.school?.canLogout == true
? SupportedSchool.uestcGraduate
@@ -149,14 +164,15 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
),
);
if (!mounted || selected == null) return;
setState(() => _selectedSchool = selected);
final saved = await widget.controller.selectSchool(selected);
if (!mounted || !saved) return;
}
if (!mounted) return;
// The only current connector is the existing UESTC graduate workflow.
// A new catalog option must provide its own connector here.
switch (selected) {
case SupportedSchool.uestcGraduate:
await _openSchool(exams: exams, chooseTerm: chooseTerm);
case SupportedSchool.uestcUndergraduate:
await _openUndergraduate(exams: exams, chooseTerm: chooseTerm);
}
} finally {
_openingSchool = false;
@@ -168,14 +184,17 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
bool chooseTerm = false,
}) async {
if (widget.schoolBuilder != null) {
final term = widget.controller.snapshot.schoolTerm;
final savedTerm = widget.controller.snapshot.schoolTerm;
final term = savedTerm?.school == SupportedSchool.uestcGraduate
? savedTerm
: null;
if (exams) {
widget.school?.openExams(
await widget.school?.openExams(
term: term,
chooseTerm: chooseTerm || term == null,
);
} else {
widget.school?.openTimetable(
await widget.school?.openTimetable(
term: term,
chooseTerm: chooseTerm || term == null,
);
@@ -201,6 +220,51 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
}
}
Future<void> _openUndergraduate({
required bool exams,
required bool chooseTerm,
}) async {
final session = widget.undergraduate, builder = widget.undergraduateBuilder;
if (session == null || builder == null) {
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('学校连接'),
content: const Text('本科连接暂未准备好,请重试。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('知道了'),
),
],
),
);
return;
}
if (exams) {
var query = UndergraduateExamQuery.fromSchedule(
widget.controller.snapshot.examSchedule,
);
if (query == null || chooseTerm) {
query = await showUndergraduateExamQueryPicker(
context,
initial: query,
now: widget.controller.clock(),
);
if (!mounted || query == null) return;
}
await session.openExams(query);
} else {
await session.openTimetable();
}
if (!mounted) return;
await Navigator.push(
context,
MaterialPageRoute<void>(builder: (_) => builder()),
);
session.setReadingActive(false);
}
Future<void> _logout() async {
final choice = await showDialog<int>(
context: context,
@@ -224,7 +288,17 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
),
);
if (choice == null || choice == 0) return;
await widget.school?.logout();
if (_knownSchool == SupportedSchool.uestcUndergraduate &&
widget.undergraduate?.canLogout == true) {
await widget.undergraduate!.logout();
widget.school?.invalidateAuthentication();
} else if (widget.school?.canLogout == true) {
await widget.school!.logout();
widget.undergraduate?.invalidateAuthentication();
} else if (widget.undergraduate?.canLogout == true) {
await widget.undergraduate!.logout();
widget.school?.invalidateAuthentication();
}
if (choice == 2) {
await widget.controller.clearCourses();
await widget.controller.clearExams();
@@ -261,6 +335,7 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
animation: Listenable.merge([
widget.controller,
if (widget.school != null) widget.school!,
if (widget.undergraduate != null) widget.undergraduate!,
]),
builder: (context, _) => Scaffold(
body: Container(
@@ -316,6 +391,11 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
onSync: () {
_school(exams: true);
},
onQueryOptions:
_knownSchool ==
SupportedSchool.uestcUndergraduate
? () => _school(exams: true, chooseTerm: true)
: null,
),
ProfilePage(
controller: widget.controller,
@@ -323,11 +403,19 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
onLogin: () => _school(selectSchool: true),
schoolName: _knownSchool?.label,
onUpdateTerm: () => _school(chooseTerm: true),
authStatus: widget.school?.statusLabel ?? '未登录',
logoutLabel: widget.school?.logoutFailed == true
authStatus:
_knownSchool ==
SupportedSchool.uestcUndergraduate
? widget.undergraduate?.statusLabel ?? '未登录'
: widget.school?.statusLabel ?? '未登录',
logoutLabel:
widget.school?.logoutFailed == true ||
widget.undergraduate?.logoutFailed == true
? '重试退出学校登录'
: '退出学校登录',
onLogout: widget.school?.canLogout == true
onLogout:
widget.school?.canLogout == true ||
widget.undergraduate?.canLogout == true
? _logout
: null,
),
+21 -3
View File
@@ -43,6 +43,26 @@ class AppSnapshot {
final Map<String, String> calendarEventIds;
final bool calendarCleanupPending;
final SupportedSchool? selectedSchool;
static void validateCourseSource(List<Course> courses, SchoolTerm? term) {
if (term?.school != SupportedSchool.uestcUndergraduate &&
courses.any((c) => c.endPeriod > 11)) {
throw const FormatException('第12节需要本科课表来源');
}
for (final course in courses) {
final under = RegExp(r'^uestc-undergraduate-(\d+)-')
.firstMatch(course.id);
if (under != null &&
(term?.school != SupportedSchool.uestcUndergraduate ||
under[1] != term?.id.toString())) {
throw const FormatException('本科课程学校或学期不一致');
}
if (term?.school == SupportedSchool.uestcUndergraduate &&
RegExp(r'^uestc-\d+-').hasMatch(course.id)) {
throw const FormatException('本科课表包含研究生课程标识');
}
}
}
static SchoolTerm? _legacyTerm(List<Course> courses, ExamSchedule? exams) {
final ids = courses
.map(
@@ -195,9 +215,7 @@ class AppSnapshot {
(k, v) => MapEntry(k as String, v as String),
),
);
if (snapshot.schoolTerm?.school != SupportedSchool.uestcUndergraduate &&
values.any((c) => c.endPeriod > 11))
throw const FormatException('第12节需要本科课表来源');
validateCourseSource(values, snapshot.schoolTerm);
return snapshot;
} catch (_) {
throw const FormatException('保存文件内容无效,未覆盖已有数据');
+1
View File
@@ -15,6 +15,7 @@ const lessonSlots = [
LessonSlot(9, 1170, 1215),
LessonSlot(10, 1220, 1265),
LessonSlot(11, 1270, 1315),
LessonSlot(12, 1320, 1365),
];
String minuteLabel(int minute) =>
'${(minute ~/ 60).toString().padLeft(2, '0')}:${(minute % 60).toString().padLeft(2, '0')}';
+45 -5
View File
@@ -6,12 +6,20 @@ import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/app_theme.dart';
import '../school/school_exam_adapter.dart';
import '../../data/exam_file_import.dart';
import '../../domain/school_identity.dart';
import '../../domain/undergraduate_exam_query.dart';
class ExamsPage extends StatelessWidget {
const ExamsPage({super.key, required this.controller, required this.onSync});
const ExamsPage({
super.key,
required this.controller,
required this.onSync,
this.onQueryOptions,
});
final AppController controller;
final VoidCallback onSync;
final VoidCallback? onQueryOptions;
Future<void> _import(BuildContext context) async {
try {
final file = await FilePicker.pickFile(
@@ -28,7 +36,7 @@ class ExamsPage extends StatelessWidget {
}
bytes.add(chunk);
}
final draft = SchoolExamAdapter.extract(utf8.decode(bytes.takeBytes()));
final draft = ExamFileImport.parse(utf8.decode(bytes.takeBytes()));
if (!context.mounted) return;
final confirmed = await showDialog<bool>(
context: context,
@@ -95,6 +103,12 @@ class ExamsPage extends StatelessWidget {
onPressed: onSync,
icon: const Icon(Icons.sync_rounded),
),
if (onQueryOptions != null)
IconButton(
tooltip: '查询选项',
onPressed: onQueryOptions,
icon: const Icon(Icons.tune_rounded),
),
IconButton(
tooltip: '导入考试 HTML',
onPressed: () => _import(context),
@@ -132,11 +146,21 @@ class ExamsPage extends StatelessWidget {
),
] else ...[
Text(
'${schedule.semesterLabel} · ${schedule.records.length} 门课程',
'${schedule.school.label} · ${schedule.semesterLabel} · ${schedule.records.length} 条考试',
style: TextStyle(fontSize: 13, color: ink.withValues(alpha: 0.55)),
),
const SizedBox(height: 18),
if (schedule.records.isEmpty) const Text('该学期暂无考试课程'),
if (schedule.school == SupportedSchool.uestcUndergraduate)
Text(
'查询类型:${UndergraduateExamQuery.fromSchedule(schedule)!.orderedTypes.map((t) => t.label).join('、')}',
style: const TextStyle(fontSize: 12),
),
if (schedule.records.isEmpty)
Text(
schedule.school == SupportedSchool.uestcUndergraduate
? '所选考试类型暂未安排'
: '该学期暂无考试课程',
),
for (final exam in schedule.records)
Padding(
padding: const EdgeInsets.only(bottom: 14),
@@ -152,9 +176,25 @@ class ExamsPage extends StatelessWidget {
),
),
const SizedBox(height: 14),
if (exam.date.isNotEmpty)
_field(Icons.calendar_today_rounded, '考试日期', exam.date),
_field(Icons.schedule_rounded, '考试时间', exam.time),
_field(Icons.location_on_outlined, '考场', exam.room),
_field(Icons.event_seat_outlined, '座位号', exam.seat),
if (exam.examTypeId != null)
_field(
Icons.category_outlined,
'考试类型',
UndergraduateExamType.fromId(exam.examTypeId!).label,
),
if (exam.status.isNotEmpty)
_field(
Icons.assignment_turned_in_outlined,
'考试情况',
exam.status,
),
if (exam.notes.isNotEmpty)
_field(Icons.info_outline_rounded, '说明', exam.notes),
if (exam.assessment.isNotEmpty)
_field(
Icons.assignment_outlined,
@@ -506,6 +506,25 @@ class SchoolSession extends ChangeNotifier {
return future.whenComplete(() => _logoutFuture = null);
}
void invalidateAuthentication() {
_openSerial++;
_pageGeneration++;
_reader.invalidate();
_flow.reset();
_poll?.cancel();
_timeout?.cancel();
_readingActive = false;
_routeClosed = true;
hasOpened = false;
pendingDraft = null;
pendingExams = null;
finding = false;
_loading = false;
connectionState = SchoolConnectionState.signedOut;
message = '学校登录已退出';
notifyListeners();
}
Future<void> _performLogout() async {
_openSerial++;
_pageGeneration++;
@@ -368,6 +368,8 @@ class UndergraduateSession extends ChangeNotifier {
void invalidateAuthentication() {
_reset();
_active = false;
hasOpened = false;
_connected = false;
awaitingAuthentication = false;
message = '学校登录已退出';
@@ -24,6 +24,10 @@ void showCourseDetails(BuildContext context, CourseOccurrence event) {
const SizedBox(height: 20),
_line(Icons.person_outline_rounded, event.course.teacher),
_line(Icons.location_on_outlined, event.course.room),
if (event.course.courseCode.isNotEmpty)
_line(Icons.tag_rounded, '课程代码:${event.course.courseCode}'),
if (event.course.notes.isNotEmpty)
_line(Icons.info_outline_rounded, event.course.notes),
_line(
Icons.schedule_rounded,
'${minuteLabel(event.start.hour * 60 + event.start.minute)}–${minuteLabel(event.end.hour * 60 + event.end.minute)} · 第 ${event.course.startPeriod}–${event.course.endPeriod} 节',
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/app_theme.dart';
import '../../domain/schedule_engine.dart';
import '../../domain/school_identity.dart';
import '../../widgets/directional_week_transition.dart';
import '../profile/settings_page.dart';
import 'week_grid.dart';
@@ -84,13 +85,24 @@ class _TimetablePageState extends State<TimetablePage> {
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
child: Text(
c.snapshot.schoolTerm!.label,
'${c.snapshot.schoolTerm!.school.label} · ${c.snapshot.schoolTerm!.label}',
style: TextStyle(
fontSize: 12,
color: ink.withValues(alpha: 0.5),
),
),
),
if (!c.isDemo &&
c.snapshot.schoolTerm != null &&
c.snapshot.selectedSchool != null &&
c.snapshot.selectedSchool != c.snapshot.schoolTerm!.school)
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
child: Text(
'当前仍为${c.snapshot.schoolTerm!.school.label}课表;新课表导入成功后才会替换。',
style: const TextStyle(fontSize: 12, color: river),
),
),
const SizedBox(height: 10),
SurfaceCard(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0),
@@ -182,6 +194,12 @@ class _TimetablePageState extends State<TimetablePage> {
monday: monday,
today: today,
settings: c.visibleSettings,
periodCount:
!c.isDemo &&
c.snapshot.schoolTerm?.school ==
SupportedSchool.uestcUndergraduate
? 12
: 11,
),
),
const SizedBox(height: 12),
+6 -4
View File
@@ -14,7 +14,9 @@ class WeekGrid extends StatelessWidget {
required this.monday,
required this.today,
required this.settings,
});
this.periodCount = 11,
}) : assert(periodCount == 11 || periodCount == 12);
final int periodCount;
final List<CourseOccurrence> events;
final DateTime monday, today;
final AppSettings settings;
@@ -111,7 +113,7 @@ class WeekGrid extends StatelessWidget {
(period - 1) * rowHeight +
(period > 4 ? gap : 0) +
(period > 8 ? gap : 0);
final height = 11 * rowHeight + 2 * gap;
final height = periodCount * rowHeight + 2 * gap;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -120,7 +122,7 @@ class WeekGrid extends StatelessWidget {
height: header + height,
child: Stack(
children: [
for (final slot in lessonSlots)
for (final slot in lessonSlots.take(periodCount))
Positioned(
top: header + top(slot.period),
left: 0,
@@ -244,7 +246,7 @@ class WeekGrid extends StatelessWidget {
),
),
),
for (var p = 1; p <= 11; p++)
for (var p = 1; p <= periodCount; p++)
Positioned(
left: 0,
right: 0,
+11
View File
@@ -12,15 +12,25 @@ import 'services/local_notification_service.dart';
import 'services/android_calendar_service.dart';
import 'features/school/school_session.dart';
import 'features/school/school_auth_page.dart';
import 'features/school/undergraduate/session.dart';
import 'features/school/undergraduate/auth_page.dart';
Widget buildApplication(
AppController controller, {
ValueNotifier<String?>? notificationSelection,
SchoolSession? school,
UndergraduateSession? undergraduate,
}) => HepanApp(
controller: controller,
notificationSelection: notificationSelection,
school: school,
undergraduate: undergraduate,
undergraduateBuilder: undergraduate == null
? null
: () => UndergraduateAuthPage(
session: undergraduate,
controller: controller,
),
schoolBuilder: school == null
? null
: () => SchoolAuthPage(session: school, controller: controller),
@@ -47,6 +57,7 @@ Future<void> main() async {
value,
notificationSelection: selection,
school: SchoolSession(),
undergraduate: UndergraduateSession(),
);
},
onHomeReady: () {