import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import '../domain/schedule_engine.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'; class HepanApp extends StatelessWidget { const HepanApp({ super.key, required this.controller, this.schoolBuilder, this.school, this.notificationSelection, }); final AppController controller; final Widget Function()? schoolBuilder; final SchoolSession? school; final ValueNotifier? notificationSelection; @override Widget build(BuildContext context) => MaterialApp( debugShowCheckedModeBanner: false, title: 'RSchedule', theme: hepanTheme(), locale: const Locale('zh', 'CN'), supportedLocales: const [Locale('zh', 'CN')], localizationsDelegates: GlobalMaterialLocalizations.delegates, home: _Shell( controller: controller, schoolBuilder: schoolBuilder, school: school, notificationSelection: notificationSelection, ), ); } class _Shell extends StatefulWidget { const _Shell({ required this.controller, this.schoolBuilder, this.school, this.notificationSelection, }); final AppController controller; final Widget Function()? schoolBuilder; final SchoolSession? school; final ValueNotifier? notificationSelection; @override State<_Shell> createState() => _ShellState(); } class _ShellState extends State<_Shell> with WidgetsBindingObserver { int _tab = 0; bool _dockHidden = false; double _scrollTravel = 0; void _selectTab(int index) { if (index == _tab) return; setState(() { _tab = index; _dockHidden = false; _scrollTravel = 0; }); } bool _scroll(ScrollNotification notification) { if (_tab == 2 || notification.metrics.axis != Axis.vertical) return false; if (notification is ScrollUpdateNotification && notification.dragDetails != null) { final delta = notification.scrollDelta ?? 0; if (delta.sign != _scrollTravel.sign) _scrollTravel = 0; _scrollTravel += delta; final hide = notification.metrics.pixels > 0 && _scrollTravel > 24; final reveal = notification.metrics.pixels <= 0 || _scrollTravel < -16; if ((hide && !_dockHidden) || (reveal && _dockHidden)) { setState(() => _dockHidden = hide); } } return false; } @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); widget.notificationSelection?.addListener(_selectNotification); WidgetsBinding.instance.addPostFrameCallback((_) => _selectNotification()); } @override void dispose() { 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(); } } void _school({bool exams = false}) { if (widget.schoolBuilder != null) { if (exams) { widget.school?.openExams(); } else { widget.school?.openTimetable(); } Navigator.push( context, MaterialPageRoute(builder: (_) => widget.schoolBuilder!()), ); } else { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('学校认证'), content: const Text('学校认证入口正在接入。你可以先体验示例课表或导入课表文件。'), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('知道了'), ), ], ), ); } } 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; await widget.school?.logout(); 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!, ]), builder: (context, _) => Scaffold( body: Container( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFF0F7F5), Color(0xFFF7F8F5), Color(0xFFEFF5F3)], ), ), 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: NotificationListener( onNotification: _scroll, child: TabDeck( index: _tab, children: [ TimetablePage(controller: widget.controller), ExamsPage( controller: widget.controller, onSync: () { _school(exams: true); }, ), ProfilePage( controller: widget.controller, onLogin: _school, authStatus: widget.school?.statusLabel ?? '未登录', logoutLabel: widget.school?.logoutFailed == true ? '重试退出学校登录' : '退出学校登录', onLogout: widget.school?.canLogout == true ? _logout : null, ), ], ), ), ), ], ), ), Positioned( left: 0, right: 0, bottom: 16 + MediaQuery.paddingOf(context).bottom, child: Center( child: IgnorePointer( ignoring: _dockHidden, child: ExcludeSemantics( excluding: _dockHidden, child: AnimatedSlide( key: const Key('floating-dock'), offset: _dockHidden ? const Offset(0, 1.8) : Offset.zero, duration: MediaQuery.disableAnimationsOf(context) ? Duration.zero : const Duration(milliseconds: 240), curve: Curves.easeOutCubic, child: AnimatedOpacity( opacity: _dockHidden ? 0 : 1, duration: const Duration(milliseconds: 180), child: GlassDock( selectedIndex: _tab, onSelected: _selectTab, ), ), ), ), ), ), ), ], ), ), ), ), ); }