feat: integrate undergraduate school workflows and period twelve
This commit is contained in:
@@ -53,6 +53,7 @@ class AppController extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> _save(AppSnapshot next) async {
|
Future<void> _save(AppSnapshot next) async {
|
||||||
_checkStorage();
|
_checkStorage();
|
||||||
|
AppSnapshot.validateCourseSource(next.courses, next.schoolTerm);
|
||||||
await store.save(next);
|
await store.save(next);
|
||||||
snapshot = next;
|
snapshot = next;
|
||||||
lastError = null;
|
lastError = null;
|
||||||
|
|||||||
+100
-12
@@ -15,6 +15,9 @@ import '../features/timetable/timetable_page.dart';
|
|||||||
import '../features/profile/profile_page.dart';
|
import '../features/profile/profile_page.dart';
|
||||||
import '../features/school/school_session.dart';
|
import '../features/school/school_session.dart';
|
||||||
import '../features/school/school_selection_page.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 {
|
class HepanApp extends StatelessWidget {
|
||||||
const HepanApp({
|
const HepanApp({
|
||||||
@@ -22,12 +25,16 @@ class HepanApp extends StatelessWidget {
|
|||||||
required this.controller,
|
required this.controller,
|
||||||
this.schoolBuilder,
|
this.schoolBuilder,
|
||||||
this.school,
|
this.school,
|
||||||
|
this.undergraduate,
|
||||||
|
this.undergraduateBuilder,
|
||||||
this.notificationSelection,
|
this.notificationSelection,
|
||||||
this.feedbackSender,
|
this.feedbackSender,
|
||||||
});
|
});
|
||||||
final AppController controller;
|
final AppController controller;
|
||||||
final Widget Function()? schoolBuilder;
|
final Widget Function()? schoolBuilder;
|
||||||
final SchoolSession? school;
|
final SchoolSession? school;
|
||||||
|
final UndergraduateSession? undergraduate;
|
||||||
|
final Widget Function()? undergraduateBuilder;
|
||||||
final ValueNotifier<String?>? notificationSelection;
|
final ValueNotifier<String?>? notificationSelection;
|
||||||
final FeedbackSender? feedbackSender;
|
final FeedbackSender? feedbackSender;
|
||||||
@override
|
@override
|
||||||
@@ -42,6 +49,8 @@ class HepanApp extends StatelessWidget {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
schoolBuilder: schoolBuilder,
|
schoolBuilder: schoolBuilder,
|
||||||
school: school,
|
school: school,
|
||||||
|
undergraduate: undergraduate,
|
||||||
|
undergraduateBuilder: undergraduateBuilder,
|
||||||
notificationSelection: notificationSelection,
|
notificationSelection: notificationSelection,
|
||||||
feedbackSender: feedbackSender ?? configuredFeedbackSender(),
|
feedbackSender: feedbackSender ?? configuredFeedbackSender(),
|
||||||
),
|
),
|
||||||
@@ -53,12 +62,16 @@ class _Shell extends StatefulWidget {
|
|||||||
required this.controller,
|
required this.controller,
|
||||||
this.schoolBuilder,
|
this.schoolBuilder,
|
||||||
this.school,
|
this.school,
|
||||||
|
this.undergraduate,
|
||||||
|
this.undergraduateBuilder,
|
||||||
this.notificationSelection,
|
this.notificationSelection,
|
||||||
required this.feedbackSender,
|
required this.feedbackSender,
|
||||||
});
|
});
|
||||||
final AppController controller;
|
final AppController controller;
|
||||||
final Widget Function()? schoolBuilder;
|
final Widget Function()? schoolBuilder;
|
||||||
final SchoolSession? school;
|
final SchoolSession? school;
|
||||||
|
final UndergraduateSession? undergraduate;
|
||||||
|
final Widget Function()? undergraduateBuilder;
|
||||||
final ValueNotifier<String?>? notificationSelection;
|
final ValueNotifier<String?>? notificationSelection;
|
||||||
final FeedbackSender feedbackSender;
|
final FeedbackSender feedbackSender;
|
||||||
@override
|
@override
|
||||||
@@ -68,11 +81,13 @@ class _Shell extends StatefulWidget {
|
|||||||
class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
||||||
int _tab = 0;
|
int _tab = 0;
|
||||||
bool _openingSchool = false;
|
bool _openingSchool = false;
|
||||||
SupportedSchool? _selectedSchool;
|
|
||||||
// Earlier imports all belong to UESTC graduate; reuse their existing term
|
// Earlier imports all belong to UESTC graduate; reuse their existing term
|
||||||
// and session without requiring students to choose again on every refresh.
|
// and session without requiring students to choose again on every refresh.
|
||||||
SupportedSchool? get _knownSchool =>
|
SupportedSchool? get _knownSchool =>
|
||||||
_selectedSchool ??
|
widget.controller.snapshot.selectedSchool ??
|
||||||
|
(widget.undergraduate?.canLogout == true
|
||||||
|
? SupportedSchool.uestcUndergraduate
|
||||||
|
: null) ??
|
||||||
(widget.controller.snapshot.schoolTerm != null ||
|
(widget.controller.snapshot.schoolTerm != null ||
|
||||||
widget.school?.canLogout == true
|
widget.school?.canLogout == true
|
||||||
? SupportedSchool.uestcGraduate
|
? SupportedSchool.uestcGraduate
|
||||||
@@ -149,14 +164,15 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (!mounted || selected == null) return;
|
if (!mounted || selected == null) return;
|
||||||
setState(() => _selectedSchool = selected);
|
final saved = await widget.controller.selectSchool(selected);
|
||||||
|
if (!mounted || !saved) return;
|
||||||
}
|
}
|
||||||
if (!mounted) 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) {
|
switch (selected) {
|
||||||
case SupportedSchool.uestcGraduate:
|
case SupportedSchool.uestcGraduate:
|
||||||
await _openSchool(exams: exams, chooseTerm: chooseTerm);
|
await _openSchool(exams: exams, chooseTerm: chooseTerm);
|
||||||
|
case SupportedSchool.uestcUndergraduate:
|
||||||
|
await _openUndergraduate(exams: exams, chooseTerm: chooseTerm);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_openingSchool = false;
|
_openingSchool = false;
|
||||||
@@ -168,14 +184,17 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
|||||||
bool chooseTerm = false,
|
bool chooseTerm = false,
|
||||||
}) async {
|
}) async {
|
||||||
if (widget.schoolBuilder != null) {
|
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) {
|
if (exams) {
|
||||||
widget.school?.openExams(
|
await widget.school?.openExams(
|
||||||
term: term,
|
term: term,
|
||||||
chooseTerm: chooseTerm || term == null,
|
chooseTerm: chooseTerm || term == null,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
widget.school?.openTimetable(
|
await widget.school?.openTimetable(
|
||||||
term: term,
|
term: term,
|
||||||
chooseTerm: chooseTerm || term == null,
|
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 {
|
Future<void> _logout() async {
|
||||||
final choice = await showDialog<int>(
|
final choice = await showDialog<int>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -224,7 +288,17 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (choice == null || choice == 0) return;
|
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) {
|
if (choice == 2) {
|
||||||
await widget.controller.clearCourses();
|
await widget.controller.clearCourses();
|
||||||
await widget.controller.clearExams();
|
await widget.controller.clearExams();
|
||||||
@@ -261,6 +335,7 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
|||||||
animation: Listenable.merge([
|
animation: Listenable.merge([
|
||||||
widget.controller,
|
widget.controller,
|
||||||
if (widget.school != null) widget.school!,
|
if (widget.school != null) widget.school!,
|
||||||
|
if (widget.undergraduate != null) widget.undergraduate!,
|
||||||
]),
|
]),
|
||||||
builder: (context, _) => Scaffold(
|
builder: (context, _) => Scaffold(
|
||||||
body: Container(
|
body: Container(
|
||||||
@@ -316,6 +391,11 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
|||||||
onSync: () {
|
onSync: () {
|
||||||
_school(exams: true);
|
_school(exams: true);
|
||||||
},
|
},
|
||||||
|
onQueryOptions:
|
||||||
|
_knownSchool ==
|
||||||
|
SupportedSchool.uestcUndergraduate
|
||||||
|
? () => _school(exams: true, chooseTerm: true)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
ProfilePage(
|
ProfilePage(
|
||||||
controller: widget.controller,
|
controller: widget.controller,
|
||||||
@@ -323,11 +403,19 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
|||||||
onLogin: () => _school(selectSchool: true),
|
onLogin: () => _school(selectSchool: true),
|
||||||
schoolName: _knownSchool?.label,
|
schoolName: _knownSchool?.label,
|
||||||
onUpdateTerm: () => _school(chooseTerm: true),
|
onUpdateTerm: () => _school(chooseTerm: true),
|
||||||
authStatus: widget.school?.statusLabel ?? '未登录',
|
authStatus:
|
||||||
logoutLabel: widget.school?.logoutFailed == true
|
_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
|
? _logout
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -43,6 +43,26 @@ class AppSnapshot {
|
|||||||
final Map<String, String> calendarEventIds;
|
final Map<String, String> calendarEventIds;
|
||||||
final bool calendarCleanupPending;
|
final bool calendarCleanupPending;
|
||||||
final SupportedSchool? selectedSchool;
|
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) {
|
static SchoolTerm? _legacyTerm(List<Course> courses, ExamSchedule? exams) {
|
||||||
final ids = courses
|
final ids = courses
|
||||||
.map(
|
.map(
|
||||||
@@ -195,9 +215,7 @@ class AppSnapshot {
|
|||||||
(k, v) => MapEntry(k as String, v as String),
|
(k, v) => MapEntry(k as String, v as String),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (snapshot.schoolTerm?.school != SupportedSchool.uestcUndergraduate &&
|
validateCourseSource(values, snapshot.schoolTerm);
|
||||||
values.any((c) => c.endPeriod > 11))
|
|
||||||
throw const FormatException('第12节需要本科课表来源');
|
|
||||||
return snapshot;
|
return snapshot;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
throw const FormatException('保存文件内容无效,未覆盖已有数据');
|
throw const FormatException('保存文件内容无效,未覆盖已有数据');
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const lessonSlots = [
|
|||||||
LessonSlot(9, 1170, 1215),
|
LessonSlot(9, 1170, 1215),
|
||||||
LessonSlot(10, 1220, 1265),
|
LessonSlot(10, 1220, 1265),
|
||||||
LessonSlot(11, 1270, 1315),
|
LessonSlot(11, 1270, 1315),
|
||||||
|
LessonSlot(12, 1320, 1365),
|
||||||
];
|
];
|
||||||
String minuteLabel(int minute) =>
|
String minuteLabel(int minute) =>
|
||||||
'${(minute ~/ 60).toString().padLeft(2, '0')}:${(minute % 60).toString().padLeft(2, '0')}';
|
'${(minute ~/ 60).toString().padLeft(2, '0')}:${(minute % 60).toString().padLeft(2, '0')}';
|
||||||
|
|||||||
@@ -6,12 +6,20 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../app/app_controller.dart';
|
import '../../app/app_controller.dart';
|
||||||
import '../../app/app_theme.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 {
|
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 AppController controller;
|
||||||
final VoidCallback onSync;
|
final VoidCallback onSync;
|
||||||
|
final VoidCallback? onQueryOptions;
|
||||||
Future<void> _import(BuildContext context) async {
|
Future<void> _import(BuildContext context) async {
|
||||||
try {
|
try {
|
||||||
final file = await FilePicker.pickFile(
|
final file = await FilePicker.pickFile(
|
||||||
@@ -28,7 +36,7 @@ class ExamsPage extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
bytes.add(chunk);
|
bytes.add(chunk);
|
||||||
}
|
}
|
||||||
final draft = SchoolExamAdapter.extract(utf8.decode(bytes.takeBytes()));
|
final draft = ExamFileImport.parse(utf8.decode(bytes.takeBytes()));
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -95,6 +103,12 @@ class ExamsPage extends StatelessWidget {
|
|||||||
onPressed: onSync,
|
onPressed: onSync,
|
||||||
icon: const Icon(Icons.sync_rounded),
|
icon: const Icon(Icons.sync_rounded),
|
||||||
),
|
),
|
||||||
|
if (onQueryOptions != null)
|
||||||
|
IconButton(
|
||||||
|
tooltip: '查询选项',
|
||||||
|
onPressed: onQueryOptions,
|
||||||
|
icon: const Icon(Icons.tune_rounded),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: '导入考试 HTML',
|
tooltip: '导入考试 HTML',
|
||||||
onPressed: () => _import(context),
|
onPressed: () => _import(context),
|
||||||
@@ -132,11 +146,21 @@ class ExamsPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
Text(
|
Text(
|
||||||
'${schedule.semesterLabel} · ${schedule.records.length} 门课程',
|
'${schedule.school.label} · ${schedule.semesterLabel} · ${schedule.records.length} 条考试',
|
||||||
style: TextStyle(fontSize: 13, color: ink.withValues(alpha: 0.55)),
|
style: TextStyle(fontSize: 13, color: ink.withValues(alpha: 0.55)),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
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)
|
for (final exam in schedule.records)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 14),
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
@@ -152,9 +176,25 @@ class ExamsPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
|
if (exam.date.isNotEmpty)
|
||||||
|
_field(Icons.calendar_today_rounded, '考试日期', exam.date),
|
||||||
_field(Icons.schedule_rounded, '考试时间', exam.time),
|
_field(Icons.schedule_rounded, '考试时间', exam.time),
|
||||||
_field(Icons.location_on_outlined, '考场', exam.room),
|
_field(Icons.location_on_outlined, '考场', exam.room),
|
||||||
_field(Icons.event_seat_outlined, '座位号', exam.seat),
|
_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)
|
if (exam.assessment.isNotEmpty)
|
||||||
_field(
|
_field(
|
||||||
Icons.assignment_outlined,
|
Icons.assignment_outlined,
|
||||||
|
|||||||
@@ -506,6 +506,25 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
return future.whenComplete(() => _logoutFuture = null);
|
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 {
|
Future<void> _performLogout() async {
|
||||||
_openSerial++;
|
_openSerial++;
|
||||||
_pageGeneration++;
|
_pageGeneration++;
|
||||||
|
|||||||
@@ -368,6 +368,8 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
|
|
||||||
void invalidateAuthentication() {
|
void invalidateAuthentication() {
|
||||||
_reset();
|
_reset();
|
||||||
|
_active = false;
|
||||||
|
hasOpened = false;
|
||||||
_connected = false;
|
_connected = false;
|
||||||
awaitingAuthentication = false;
|
awaitingAuthentication = false;
|
||||||
message = '学校登录已退出';
|
message = '学校登录已退出';
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ void showCourseDetails(BuildContext context, CourseOccurrence event) {
|
|||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_line(Icons.person_outline_rounded, event.course.teacher),
|
_line(Icons.person_outline_rounded, event.course.teacher),
|
||||||
_line(Icons.location_on_outlined, event.course.room),
|
_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(
|
_line(
|
||||||
Icons.schedule_rounded,
|
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} 节',
|
'${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_controller.dart';
|
||||||
import '../../app/app_theme.dart';
|
import '../../app/app_theme.dart';
|
||||||
import '../../domain/schedule_engine.dart';
|
import '../../domain/schedule_engine.dart';
|
||||||
|
import '../../domain/school_identity.dart';
|
||||||
import '../../widgets/directional_week_transition.dart';
|
import '../../widgets/directional_week_transition.dart';
|
||||||
import '../profile/settings_page.dart';
|
import '../profile/settings_page.dart';
|
||||||
import 'week_grid.dart';
|
import 'week_grid.dart';
|
||||||
@@ -84,13 +85,24 @@ class _TimetablePageState extends State<TimetablePage> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
|
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
c.snapshot.schoolTerm!.label,
|
'${c.snapshot.schoolTerm!.school.label} · ${c.snapshot.schoolTerm!.label}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: ink.withValues(alpha: 0.5),
|
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),
|
const SizedBox(height: 10),
|
||||||
SurfaceCard(
|
SurfaceCard(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0),
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0),
|
||||||
@@ -182,6 +194,12 @@ class _TimetablePageState extends State<TimetablePage> {
|
|||||||
monday: monday,
|
monday: monday,
|
||||||
today: today,
|
today: today,
|
||||||
settings: c.visibleSettings,
|
settings: c.visibleSettings,
|
||||||
|
periodCount:
|
||||||
|
!c.isDemo &&
|
||||||
|
c.snapshot.schoolTerm?.school ==
|
||||||
|
SupportedSchool.uestcUndergraduate
|
||||||
|
? 12
|
||||||
|
: 11,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ class WeekGrid extends StatelessWidget {
|
|||||||
required this.monday,
|
required this.monday,
|
||||||
required this.today,
|
required this.today,
|
||||||
required this.settings,
|
required this.settings,
|
||||||
});
|
this.periodCount = 11,
|
||||||
|
}) : assert(periodCount == 11 || periodCount == 12);
|
||||||
|
final int periodCount;
|
||||||
final List<CourseOccurrence> events;
|
final List<CourseOccurrence> events;
|
||||||
final DateTime monday, today;
|
final DateTime monday, today;
|
||||||
final AppSettings settings;
|
final AppSettings settings;
|
||||||
@@ -111,7 +113,7 @@ class WeekGrid extends StatelessWidget {
|
|||||||
(period - 1) * rowHeight +
|
(period - 1) * rowHeight +
|
||||||
(period > 4 ? gap : 0) +
|
(period > 4 ? gap : 0) +
|
||||||
(period > 8 ? gap : 0);
|
(period > 8 ? gap : 0);
|
||||||
final height = 11 * rowHeight + 2 * gap;
|
final height = periodCount * rowHeight + 2 * gap;
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -120,7 +122,7 @@ class WeekGrid extends StatelessWidget {
|
|||||||
height: header + height,
|
height: header + height,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
for (final slot in lessonSlots)
|
for (final slot in lessonSlots.take(periodCount))
|
||||||
Positioned(
|
Positioned(
|
||||||
top: header + top(slot.period),
|
top: header + top(slot.period),
|
||||||
left: 0,
|
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(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
|
|||||||
@@ -12,15 +12,25 @@ import 'services/local_notification_service.dart';
|
|||||||
import 'services/android_calendar_service.dart';
|
import 'services/android_calendar_service.dart';
|
||||||
import 'features/school/school_session.dart';
|
import 'features/school/school_session.dart';
|
||||||
import 'features/school/school_auth_page.dart';
|
import 'features/school/school_auth_page.dart';
|
||||||
|
import 'features/school/undergraduate/session.dart';
|
||||||
|
import 'features/school/undergraduate/auth_page.dart';
|
||||||
|
|
||||||
Widget buildApplication(
|
Widget buildApplication(
|
||||||
AppController controller, {
|
AppController controller, {
|
||||||
ValueNotifier<String?>? notificationSelection,
|
ValueNotifier<String?>? notificationSelection,
|
||||||
SchoolSession? school,
|
SchoolSession? school,
|
||||||
|
UndergraduateSession? undergraduate,
|
||||||
}) => HepanApp(
|
}) => HepanApp(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
notificationSelection: notificationSelection,
|
notificationSelection: notificationSelection,
|
||||||
school: school,
|
school: school,
|
||||||
|
undergraduate: undergraduate,
|
||||||
|
undergraduateBuilder: undergraduate == null
|
||||||
|
? null
|
||||||
|
: () => UndergraduateAuthPage(
|
||||||
|
session: undergraduate,
|
||||||
|
controller: controller,
|
||||||
|
),
|
||||||
schoolBuilder: school == null
|
schoolBuilder: school == null
|
||||||
? null
|
? null
|
||||||
: () => SchoolAuthPage(session: school, controller: controller),
|
: () => SchoolAuthPage(session: school, controller: controller),
|
||||||
@@ -47,6 +57,7 @@ Future<void> main() async {
|
|||||||
value,
|
value,
|
||||||
notificationSelection: selection,
|
notificationSelection: selection,
|
||||||
school: SchoolSession(),
|
school: SchoolSession(),
|
||||||
|
undergraduate: UndergraduateSession(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onHomeReady: () {
|
onHomeReady: () {
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
test('invalid lesson, weekday and week values are rejected', () {
|
test('invalid lesson, weekday and week values are rejected', () {
|
||||||
expect(() => course(first: 0), throwsArgumentError);
|
expect(() => course(first: 0), throwsArgumentError);
|
||||||
expect(() => course(last: 12), throwsArgumentError);
|
expect(() => course(last: 13), throwsArgumentError);
|
||||||
expect(() => course(day: 8), throwsArgumentError);
|
expect(() => course(day: 8), throwsArgumentError);
|
||||||
expect(() => course(weeks: {0}), throwsArgumentError);
|
expect(() => course(weeks: {0}), throwsArgumentError);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -7,6 +7,37 @@ import 'package:hepan_everyday/domain/app_snapshot.dart';
|
|||||||
import 'package:hepan_everyday/domain/course.dart';
|
import 'package:hepan_everyday/domain/course.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
test(
|
||||||
|
'managed undergraduate identifiers cannot lose their school or term',
|
||||||
|
() {
|
||||||
|
final c = Course(
|
||||||
|
id: 'uestc-undergraduate-523-managed',
|
||||||
|
name: '课程',
|
||||||
|
teacher: '教师',
|
||||||
|
room: '教室',
|
||||||
|
weekday: 1,
|
||||||
|
startPeriod: 1,
|
||||||
|
endPeriod: 2,
|
||||||
|
weeks: {1},
|
||||||
|
source: CourseSource.school,
|
||||||
|
);
|
||||||
|
final saved = AppSnapshot(
|
||||||
|
courses: [c],
|
||||||
|
schoolTerm: SchoolTerm(523, school: SupportedSchool.uestcUndergraduate),
|
||||||
|
).toJson();
|
||||||
|
expect(
|
||||||
|
() => AppSnapshot.fromJson({
|
||||||
|
...saved,
|
||||||
|
'schoolTerm': {'id': 73},
|
||||||
|
}),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => AppSnapshot.fromJson({...saved, 'schoolTerm': null}),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
SchoolTerm under(int id) =>
|
SchoolTerm under(int id) =>
|
||||||
SchoolTerm(id, school: SupportedSchool.uestcUndergraduate);
|
SchoolTerm(id, school: SupportedSchool.uestcUndergraduate);
|
||||||
test('undergraduate semester labels and final exam URLs', () {
|
test('undergraduate semester labels and final exam URLs', () {
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:timezone/data/latest.dart' as tzdata;
|
||||||
|
import 'package:hepan_everyday/app/app_controller.dart';
|
||||||
|
import 'package:hepan_everyday/domain/app_snapshot.dart';
|
||||||
|
import 'package:hepan_everyday/domain/app_settings.dart';
|
||||||
|
import 'package:hepan_everyday/domain/school_identity.dart';
|
||||||
|
import 'package:hepan_everyday/domain/school_term.dart';
|
||||||
|
import 'package:hepan_everyday/domain/schedule_engine.dart';
|
||||||
|
import 'package:hepan_everyday/features/timetable/timetable_page.dart';
|
||||||
|
import 'package:hepan_everyday/features/timetable/week_grid.dart';
|
||||||
|
import 'package:hepan_everyday/services/local_notification_service.dart';
|
||||||
|
import 'package:hepan_everyday/services/android_calendar_service.dart';
|
||||||
|
|
||||||
|
import '../support/fakes.dart';
|
||||||
|
import '../domain/schedule_engine_test.dart' show course;
|
||||||
|
import '../services/notification_service_test.dart' show AlarmGateway;
|
||||||
|
import '../services/calendar_service_test.dart' show CalendarProvider;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(tzdata.initializeTimeZones);
|
||||||
|
testWidgets('row count follows imported school across empty weeks and demo', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(360, 800);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
final controller = AppController(
|
||||||
|
MemoryStore(),
|
||||||
|
FakeNotifications(),
|
||||||
|
FakeCalendar(),
|
||||||
|
() => DateTime.utc(2026, 9, 7),
|
||||||
|
);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
final settings = AppSettings(
|
||||||
|
semester: Semester(startDate: DateTime(2026, 9, 7), totalWeeks: 20),
|
||||||
|
);
|
||||||
|
controller.snapshot = AppSnapshot(
|
||||||
|
settings: settings,
|
||||||
|
schoolTerm: SchoolTerm(523, school: SupportedSchool.uestcUndergraduate),
|
||||||
|
courses: [
|
||||||
|
course(first: 9, last: 12, weeks: {2}),
|
||||||
|
],
|
||||||
|
selectedSchool: SupportedSchool.uestcGraduate,
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(body: TimetablePage(controller: controller)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.widget<WeekGrid>(find.byType(WeekGrid)).periodCount, 12);
|
||||||
|
expect(
|
||||||
|
find.byWidgetPredicate(
|
||||||
|
(w) => w is Text && w.data == '12' && w.style?.color == null,
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(tester.getRect(find.text('周日')).right, lessThanOrEqualTo(360));
|
||||||
|
await controller.showDemo();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(body: TimetablePage(controller: controller)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.widget<WeekGrid>(find.byType(WeekGrid)).periodCount, 11);
|
||||||
|
});
|
||||||
|
test('period twelve drives reminders and calendar ending at 22:45', () async {
|
||||||
|
final term = Semester(startDate: DateTime(2026, 9, 7), totalWeeks: 1),
|
||||||
|
events = ScheduleEngine.occurrences([
|
||||||
|
course(day: 1, first: 12, last: 12),
|
||||||
|
], Semester(startDate: DateTime(2026, 9, 7), totalWeeks: 1));
|
||||||
|
expect(events.single.start.hour, 22);
|
||||||
|
expect(events.single.end.hour, 22);
|
||||||
|
expect(events.single.end.minute, 45);
|
||||||
|
final night = ScheduleEngine.occurrences([
|
||||||
|
course(day: 1, first: 9, last: 12),
|
||||||
|
], term).single;
|
||||||
|
expect(night.start.hour, 19);
|
||||||
|
expect(night.start.minute, 30);
|
||||||
|
expect(night.end.minute, 45);
|
||||||
|
final gateway = AlarmGateway();
|
||||||
|
await LocalNotificationService(gateway).reconcile(
|
||||||
|
events,
|
||||||
|
AppSettings(semester: term, reminderOffsets: [5, 15]),
|
||||||
|
DateTime.utc(2026, 9, 6),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
gateway.alarms.values
|
||||||
|
.map((a) => '${a.fireAt.hour}:${a.fireAt.minute}')
|
||||||
|
.toSet(),
|
||||||
|
{'21:55', '21:45'},
|
||||||
|
);
|
||||||
|
final provider = CalendarProvider();
|
||||||
|
await AndroidCalendarService(provider).sync(events, 'local', {});
|
||||||
|
expect(
|
||||||
|
provider.drafts.values.single.endMs,
|
||||||
|
events.single.end.millisecondsSinceEpoch,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:timezone/data/latest.dart' as tzdata;
|
||||||
|
import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
|
||||||
|
import 'package:hepan_everyday/app/hepan_app.dart';
|
||||||
|
import 'package:hepan_everyday/app/app_controller.dart';
|
||||||
|
import 'package:hepan_everyday/domain/app_snapshot.dart';
|
||||||
|
import 'package:hepan_everyday/domain/school_identity.dart';
|
||||||
|
import 'package:hepan_everyday/domain/exam_schedule.dart';
|
||||||
|
import 'package:hepan_everyday/domain/undergraduate_exam_query.dart';
|
||||||
|
import 'package:hepan_everyday/features/school/school_session.dart';
|
||||||
|
import 'package:hepan_everyday/features/school/undergraduate/session.dart';
|
||||||
|
|
||||||
|
import '../support/fakes.dart';
|
||||||
|
import '../support/fake_school_web.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(tzdata.initializeTimeZones);
|
||||||
|
testWidgets(
|
||||||
|
'undergraduate selection persists and enters its manual workflow',
|
||||||
|
(tester) async {
|
||||||
|
final platform = FakeSchoolPlatform();
|
||||||
|
WebViewPlatform.instance = platform;
|
||||||
|
final store = MemoryStore(),
|
||||||
|
controller = AppController(
|
||||||
|
MemoryStore(),
|
||||||
|
FakeNotifications(),
|
||||||
|
FakeCalendar(),
|
||||||
|
() => DateTime.utc(2026, 10, 14),
|
||||||
|
);
|
||||||
|
final under = UndergraduateSession();
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
addTearDown(under.dispose);
|
||||||
|
await controller.initialize();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
HepanApp(
|
||||||
|
controller: controller,
|
||||||
|
undergraduate: under,
|
||||||
|
undergraduateBuilder: () => const Scaffold(body: Text('本科网页')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(platform.controllerCreations, 0);
|
||||||
|
await tester.tap(find.bySemanticsLabel('个人设置'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '选择学校'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('电子科技大学(研究生)'), findsOneWidget);
|
||||||
|
expect(find.text('电子科技大学(本科生)'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('电子科技大学(本科生)'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('本科网页'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
controller.snapshot.selectedSchool,
|
||||||
|
SupportedSchool.uestcUndergraduate,
|
||||||
|
);
|
||||||
|
expect(platform.web.loads, ['https://eams.uestc.edu.cn/eams/']);
|
||||||
|
expect(under.requestingExams, isFalse);
|
||||||
|
store.value = AppSnapshot.fromJson(controller.snapshot.toJson());
|
||||||
|
expect(store.value!.selectedSchool, SupportedSchool.uestcUndergraduate);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
testWidgets('failed school preference does not create a browser', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final platform = FakeSchoolPlatform();
|
||||||
|
WebViewPlatform.instance = platform;
|
||||||
|
final store = MemoryStore(), under = UndergraduateSession();
|
||||||
|
final controller = AppController(
|
||||||
|
store,
|
||||||
|
FakeNotifications(),
|
||||||
|
FakeCalendar(),
|
||||||
|
DateTime.now,
|
||||||
|
);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
addTearDown(under.dispose);
|
||||||
|
await controller.initialize();
|
||||||
|
store.failSave = true;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
HepanApp(
|
||||||
|
controller: controller,
|
||||||
|
undergraduate: under,
|
||||||
|
undergraduateBuilder: () => const Scaffold(body: Text('本科网页')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.bySemanticsLabel('个人设置'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '选择学校'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('电子科技大学(本科生)'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(platform.controllerCreations, 0);
|
||||||
|
expect(controller.snapshot.selectedSchool, isNull);
|
||||||
|
expect(find.text('本科网页'), findsNothing);
|
||||||
|
});
|
||||||
|
testWidgets('exam refresh reuses the successful undergraduate query', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final platform = FakeSchoolPlatform();
|
||||||
|
WebViewPlatform.instance = platform;
|
||||||
|
final under = UndergraduateSession();
|
||||||
|
final store = MemoryStore(
|
||||||
|
AppSnapshot(
|
||||||
|
selectedSchool: SupportedSchool.uestcUndergraduate,
|
||||||
|
examSchedule: ExamSchedule(
|
||||||
|
semesterLabel: '2025-2026第二学期',
|
||||||
|
termId: '503',
|
||||||
|
school: SupportedSchool.uestcUndergraduate,
|
||||||
|
requestedExamTypes: {1, 2},
|
||||||
|
records: [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final controller = AppController(
|
||||||
|
store,
|
||||||
|
FakeNotifications(),
|
||||||
|
FakeCalendar(),
|
||||||
|
DateTime.now,
|
||||||
|
);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
addTearDown(under.dispose);
|
||||||
|
await controller.initialize();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
HepanApp(
|
||||||
|
controller: controller,
|
||||||
|
undergraduate: under,
|
||||||
|
undergraduateBuilder: () => const Scaffold(body: Text('本科网页')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.bySemanticsLabel('考试'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byTooltip('从学校更新考试'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('本科网页'), findsOneWidget);
|
||||||
|
expect(under.query!.term.id, 503);
|
||||||
|
expect(under.query!.types, {
|
||||||
|
UndergraduateExamType.finalExam,
|
||||||
|
UndergraduateExamType.midterm,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
testWidgets('query option cancellation starts no login', (tester) async {
|
||||||
|
final platform = FakeSchoolPlatform();
|
||||||
|
WebViewPlatform.instance = platform;
|
||||||
|
final under = UndergraduateSession();
|
||||||
|
final controller = AppController(
|
||||||
|
MemoryStore(
|
||||||
|
AppSnapshot(selectedSchool: SupportedSchool.uestcUndergraduate),
|
||||||
|
),
|
||||||
|
FakeNotifications(),
|
||||||
|
FakeCalendar(),
|
||||||
|
DateTime.now,
|
||||||
|
);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
addTearDown(under.dispose);
|
||||||
|
await controller.initialize();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
HepanApp(
|
||||||
|
controller: controller,
|
||||||
|
undergraduate: under,
|
||||||
|
undergraduateBuilder: () => const Scaffold(body: Text('本科网页')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.bySemanticsLabel('考试'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byTooltip('查询选项'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('取消'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(platform.controllerCreations, 0);
|
||||||
|
});
|
||||||
|
testWidgets(
|
||||||
|
'shared logout invalidates both schools without creating another browser',
|
||||||
|
(tester) async {
|
||||||
|
final platform = FakeSchoolPlatform();
|
||||||
|
WebViewPlatform.instance = platform;
|
||||||
|
final under = UndergraduateSession(), grad = SchoolSession();
|
||||||
|
await grad.openTimetable();
|
||||||
|
await under.openTimetable();
|
||||||
|
final controller = AppController(
|
||||||
|
MemoryStore(
|
||||||
|
AppSnapshot(selectedSchool: SupportedSchool.uestcUndergraduate),
|
||||||
|
),
|
||||||
|
FakeNotifications(),
|
||||||
|
FakeCalendar(),
|
||||||
|
DateTime.now,
|
||||||
|
);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
addTearDown(under.dispose);
|
||||||
|
addTearDown(grad.dispose);
|
||||||
|
await controller.initialize();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
HepanApp(controller: controller, school: grad, undergraduate: under),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.bySemanticsLabel('个人设置'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('退出学校登录'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('保留课表和考试'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(platform.controllerCreations, 2);
|
||||||
|
expect(under.canLogout, isFalse);
|
||||||
|
expect(grad.canLogout, isFalse);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user