import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import '../domain/schedule_engine.dart'; import '../features/feedback/feedback_http_sender.dart'; import '../features/feedback/feedback_submission.dart'; import '../features/timetable/course_details.dart'; import 'app_controller.dart'; import 'app_theme.dart'; import '../widgets/glass_dock.dart'; import '../widgets/tab_deck.dart'; import '../features/exams/exams_page.dart'; 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'; import '../services/app_update_service.dart'; import '../features/updates/update_prompt.dart'; class HepanApp extends StatelessWidget { const HepanApp({ super.key, required this.controller, this.schoolBuilder, this.school, this.undergraduate, this.undergraduateBuilder, this.notificationSelection, this.feedbackSender, this.updateService, this.openUpdateDownload = openUpdateInBrowser, }); final AppController controller; final Widget Function()? schoolBuilder; final SchoolSession? school; final UndergraduateSession? undergraduate; final Widget Function()? undergraduateBuilder; final ValueNotifier? notificationSelection; final FeedbackSender? feedbackSender; final AppUpdateService? updateService; final UpdateDownloadOpener openUpdateDownload; @override Widget build(BuildContext context) => MaterialApp( debugShowCheckedModeBanner: false, title: 'TPSchedule', theme: hepanTheme(), locale: const Locale('zh', 'CN'), supportedLocales: const [Locale('zh', 'CN')], localizationsDelegates: GlobalMaterialLocalizations.delegates, home: _Shell( controller: controller, schoolBuilder: schoolBuilder, school: school, undergraduate: undergraduate, undergraduateBuilder: undergraduateBuilder, notificationSelection: notificationSelection, feedbackSender: feedbackSender ?? configuredFeedbackSender(), updateService: updateService, openUpdateDownload: openUpdateDownload, ), ); } class _Shell extends StatefulWidget { const _Shell({ required this.controller, this.schoolBuilder, this.school, this.undergraduate, this.undergraduateBuilder, this.notificationSelection, required this.feedbackSender, this.updateService, required this.openUpdateDownload, }); final AppController controller; final Widget Function()? schoolBuilder; final SchoolSession? school; final UndergraduateSession? undergraduate; final Widget Function()? undergraduateBuilder; final ValueNotifier? notificationSelection; final FeedbackSender feedbackSender; final AppUpdateService? updateService; final UpdateDownloadOpener openUpdateDownload; @override State<_Shell> createState() => _ShellState(); } class _ShellState extends State<_Shell> with WidgetsBindingObserver { int _tab = 0; bool _openingSchool = false; bool _checkingUpdate = false; bool _updatePromptOpen = false; Timer? _startupUpdate; Future _checkUpdate({bool silent = false}) async { if (_checkingUpdate || _updatePromptOpen || widget.updateService == null) { return; } setState(() => _checkingUpdate = true); try { final release = await widget.updateService!.check(); if (mounted) setState(() => _checkingUpdate = false); if (!mounted || ModalRoute.of(context)?.isCurrent != true) return; if (release != null) { _updatePromptOpen = true; await showUpdatePrompt( context, release, openDownload: widget.openUpdateDownload, ); } else if (!silent) { ScaffoldMessenger.of(context) .showSnackBar(const SnackBar(content: Text('当前已是最新版本'))); } } on UpdateCheckException catch (error) { if (!silent && mounted) { ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(error.message))); } } finally { _updatePromptOpen = false; if (mounted) setState(() => _checkingUpdate = false); } } // Restore the saved school; legacy graduate imports infer their source. SupportedSchool? get _knownSchool => widget.controller.snapshot.selectedSchool ?? (widget.undergraduate?.canLogout == true ? SupportedSchool.uestcUndergraduate : null) ?? (widget.controller.snapshot.schoolTerm != null || widget.school?.canLogout == true ? SupportedSchool.uestcGraduate : null); void _selectTab(int index) { if (index == _tab) return; setState(() => _tab = index); } @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); widget.notificationSelection?.addListener(_selectNotification); WidgetsBinding.instance.addPostFrameCallback((_) => _selectNotification()); if (widget.updateService != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _startupUpdate = Timer(const Duration(seconds: 2), () { if (mounted) unawaited(_checkUpdate(silent: true)); }); }); } } @override void dispose() { _startupUpdate?.cancel(); WidgetsBinding.instance.removeObserver(this); widget.notificationSelection?.removeListener(_selectNotification); super.dispose(); } Future _selectNotification() async { final key = widget.notificationSelection?.value; if (key == null || !mounted) { return; } widget.notificationSelection!.value = null; await widget.controller.leaveDemo(); if (!mounted) { return; } Navigator.of(context).popUntil((route) => route.isFirst); setState(() => _tab = 0); final semester = widget.controller.snapshot.settings.semester; if (semester == null) { return; } final events = ScheduleEngine.occurrences( widget.controller.snapshot.courses, semester, ).where((e) => e.key == key); if (events.isNotEmpty) { showCourseDetails(context, events.first); } else { ScaffoldMessenger.of(context) .showSnackBar(const SnackBar(content: Text('这门课程已更新,请查看当前课表'))); } } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { widget.controller.refreshServices(); } } Future _school({ bool exams = false, bool chooseTerm = false, bool selectSchool = false, }) async { if (_openingSchool) return; _openingSchool = true; try { var selected = _knownSchool; if (selectSchool || selected == null) { selected = await Navigator.push( context, MaterialPageRoute( builder: (_) => SchoolSelectionPage(selected: _knownSchool), ), ); if (!mounted || selected == null) return; final saved = await widget.controller.selectSchool(selected); if (!mounted || !saved) return; } if (!mounted) return; switch (selected) { case SupportedSchool.uestcGraduate: await _openSchool(exams: exams, chooseTerm: chooseTerm); case SupportedSchool.uestcUndergraduate: await _openUndergraduate(exams: exams, chooseTerm: chooseTerm); } } finally { _openingSchool = false; } } Future _openSchool({ bool exams = false, bool chooseTerm = false, }) async { if (widget.schoolBuilder != null) { final savedTerm = widget.controller.snapshot.schoolTerm; final term = savedTerm?.school == SupportedSchool.uestcGraduate ? savedTerm : null; if (exams) { await widget.school?.openExams( term: term, chooseTerm: chooseTerm || term == null, ); } else { await widget.school?.openTimetable( term: term, chooseTerm: chooseTerm || term == null, ); } if (!mounted) return; await Navigator.push( context, MaterialPageRoute(builder: (_) => widget.schoolBuilder!()), ); } else { await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('学校认证'), content: const Text('学校认证入口正在接入。你可以先体验示例课表或导入课表文件。'), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('知道了'), ), ], ), ); } } Future _openUndergraduate({ required bool exams, required bool chooseTerm, }) async { final session = widget.undergraduate, builder = widget.undergraduateBuilder; if (session == null || builder == null) { await showDialog( 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(builder: (_) => builder()), ); session.setReadingActive(false); } Future _logout() async { final choice = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('退出学校登录'), content: const Text('已导入的课表和考试信息是否保留在手机中?'), actions: [ TextButton( onPressed: () => Navigator.pop(context, 0), child: const Text('取消'), ), TextButton( onPressed: () => Navigator.pop(context, 1), child: const Text('保留课表和考试'), ), TextButton( onPressed: () => Navigator.pop(context, 2), child: const Text('清除课表和考试'), ), ], ), ); if (choice == null || choice == 0) return; 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(); } } Future _startFresh() async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('重新建立课表'), content: const Text( '损坏文件会保留为恢复副本,然后重新建立空课表。已有课程通知会取消;手机日历中原有课程日程需手动处理。', ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('取消'), ), FilledButton( onPressed: () => Navigator.pop(context, true), child: const Text('保留损坏文件并重新建立'), ), ], ), ); if (confirmed == true) { await widget.controller.startFreshData(); } } @override Widget build(BuildContext context) => AnimatedBuilder( animation: Listenable.merge([ widget.controller, if (widget.school != null) widget.school!, if (widget.undergraduate != null) widget.undergraduate!, ]), builder: (context, _) => Scaffold( body: Container( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFF0F6FF), Color(0xFFF7FAFF), Color(0xFFEDF4FF)], ), ), child: SafeArea( bottom: false, child: Stack( children: [ Positioned.fill( child: Column( children: [ if (widget.controller.lastError != null) Container( width: double.infinity, color: const Color(0xFFF8ECE6), padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.controller.lastError!, style: const TextStyle(fontSize: 12), ), if (!widget.controller.storageReady) TextButton( onPressed: widget.controller.restoreBackup, child: const Text('恢复最近备份'), ), if (!widget.controller.storageReady) TextButton( onPressed: _startFresh, child: const Text('重新建立课表'), ), ], ), ), Expanded( child: TabDeck( index: _tab, children: [ TimetablePage( controller: widget.controller, onSync: _school, ), ExamsPage( controller: widget.controller, onSync: () { _school(exams: true); }, onQueryOptions: _knownSchool == SupportedSchool.uestcUndergraduate ? () => _school(exams: true, chooseTerm: true) : null, ), ProfilePage( controller: widget.controller, feedbackSender: widget.feedbackSender, onCheckUpdates: widget.updateService == null ? null : () => _checkUpdate(), checkingUpdates: _checkingUpdate, onLogin: () => _school(selectSchool: true), schoolName: _knownSchool?.label, onUpdateTerm: () => _school(chooseTerm: 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 || widget.undergraduate?.canLogout == true ? _logout : null, ), ], ), ), ], ), ), Positioned( left: 0, right: 0, bottom: 16 + MediaQuery.paddingOf(context).bottom, child: Center( child: GlassDock( key: const Key('floating-dock'), selectedIndex: _tab, onSelected: _selectTab, ), ), ), ], ), ), ), ), ); }