diff --git a/lib/models/course_table.dart b/lib/models/course_table.dart index e2e4807..ac62e8e 100644 --- a/lib/models/course_table.dart +++ b/lib/models/course_table.dart @@ -194,24 +194,99 @@ class SemesterInfo { for (final yearEntry in semesters.entries) { for (final semEntry in yearEntry.value.entries) { if (semEntry.value == semesterId) { - return '${yearEntry.key} ${semEntry.key}学期'; + return '${yearEntry.key} ${semesterTermDisplayName(semEntry.key)}学期'; } } } return null; } +} - List> get allSemesters { - final result = >[]; - for (final yearEntry in semesters.entries) { - for (final semEntry in yearEntry.value.entries) { - result.add( - MapEntry(semEntry.value, '${yearEntry.key} ${semEntry.key}学期'), - ); - } - } - return result; +/// Canonical academic-year term names, in chronological order. Index+1 is +/// also the term number the backend expects (秋=1, 春=2, 暑=3). +const List kSemesterTermNames = ['秋', '春', '暑']; + +/// The backend sometimes keys a year's terms by this display name directly +/// (e.g. "秋"), sometimes by the term number as a string (e.g. "1"). Either +/// way, resolve it to the Chinese term name for display. +String semesterTermDisplayName(String rawTermKey) { + final asNumber = int.tryParse(rawTermKey); + if (asNumber != null && + asNumber >= 1 && + asNumber <= kSemesterTermNames.length) { + return kSemesterTermNames[asNumber - 1]; + } + for (final name in kSemesterTermNames) { + if (rawTermKey.contains(name)) return name; + } + return rawTermKey; +} + +/// Chronological rank of a term key (0=秋, 1=春, 2=暑); unrecognized keys +/// sort last. +int semesterTermRank(String rawTermKey) { + final index = kSemesterTermNames.indexOf(semesterTermDisplayName(rawTermKey)); + return index < 0 ? kSemesterTermNames.length : index; +} + +/// Full school-calendar record for a specific queried year+semester, as +/// returned by `/schedule/term_begin`. `date`/`weekOfTerm` are a snapshot of +/// where "today" (server time, at fetch time) falls within the QUERIED term — +/// they go stale as real time passes, so treat them as a hint, not live state +/// (`ScheduleService` recomputes the live week/in-term status from +/// [termBegin] + [allTeachWeeks] instead of trusting this snapshot). +class TermCalendar { + final int code; + final String schoolYearTerm; + final DateTime termBegin; + final DateTime? snapshotDate; + final int? snapshotWeekOfTerm; + final int allTeachWeeks; + final int allTermWeeks; + final int amClasses; + final int pmClasses; + final int eveClasses; + + const TermCalendar({ + required this.code, + required this.schoolYearTerm, + required this.termBegin, + required this.snapshotDate, + required this.snapshotWeekOfTerm, + required this.allTeachWeeks, + required this.allTermWeeks, + required this.amClasses, + required this.pmClasses, + required this.eveClasses, + }); + + factory TermCalendar.fromJson(Map json) { + return TermCalendar( + code: json['code'] as int? ?? 200, + schoolYearTerm: json['schoolYearTerm'] as String? ?? '', + termBegin: DateTime.parse(json['termBegin'] as String), + snapshotDate: DateTime.tryParse(json['date'] as String? ?? ''), + snapshotWeekOfTerm: int.tryParse(json['weekOfTerm'] as String? ?? ''), + allTeachWeeks: json['allTeachWeeks'] as int? ?? 0, + allTermWeeks: json['allTermWeeks'] as int? ?? 0, + amClasses: json['amClasses'] as int? ?? 0, + pmClasses: json['pmClasses'] as int? ?? 0, + eveClasses: json['eveClasses'] as int? ?? 0, + ); } + + Map toJson() => { + 'code': code, + 'schoolYearTerm': schoolYearTerm, + 'termBegin': termBegin.toIso8601String(), + 'date': snapshotDate?.toIso8601String(), + 'weekOfTerm': snapshotWeekOfTerm?.toString(), + 'allTeachWeeks': allTeachWeeks, + 'allTermWeeks': allTermWeeks, + 'amClasses': amClasses, + 'pmClasses': pmClasses, + 'eveClasses': eveClasses, + }; } /// Convert EamsCourse list to display Course list, grouping consecutive periods. diff --git a/lib/pages/schedule_page.dart b/lib/pages/schedule_page.dart index 78785ab..ea90f5b 100644 --- a/lib/pages/schedule_page.dart +++ b/lib/pages/schedule_page.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:open_filex/open_filex.dart'; +import 'package:reel_text/reel_text.dart'; import 'package:url_launcher/url_launcher.dart'; import '../models/course.dart'; @@ -102,7 +104,7 @@ class _SchedulePageState extends State { void _rebuildCourses() { if (!mounted) return; setState(() { - _currentWeek = _schedule.currentWeek().clamp(1, 25).toInt(); + _currentWeek = _schedule.currentWeek().clamp(1, _schedule.totalWeeks).toInt(); final table = _schedule.courseTable; if (table != null) { if (table.periods.isNotEmpty) { @@ -132,7 +134,8 @@ class _SchedulePageState extends State { } void _goToCurrentWeek() { - final computedWeek = _schedule.currentWeek().clamp(1, 25).toInt(); + final computedWeek = + _schedule.currentWeek().clamp(1, _schedule.totalWeeks).toInt(); _setWeek(computedWeek); } @@ -152,7 +155,7 @@ class _SchedulePageState extends State { void _setCurrentWeek(int week) { final old = _currentWeek; setState(() { - _currentWeek = week.clamp(1, 25).toInt(); + _currentWeek = week.clamp(1, _schedule.totalWeeks).toInt(); _slideDirection = _currentWeek > old ? 1 : (_currentWeek < old ? -1 : 0); _filterCoursesForWeek(); }); @@ -175,7 +178,7 @@ class _SchedulePageState extends State { return eamsToDisplayCourses( table.courses, - week.clamp(1, 25).toInt(), + week.clamp(1, _schedule.totalWeeks).toInt(), includeGhosts: _showGhostCourses, ); } @@ -184,7 +187,7 @@ class _SchedulePageState extends State { final termBegin = _schedule.termBegin; if (termBegin != null) { final weekStartDate = termBegin.add( - Duration(days: (week.clamp(1, 25).toInt() - 1) * 7), + Duration(days: (week.clamp(1, _schedule.totalWeeks).toInt() - 1) * 7), ); return weekStartDate.subtract(Duration(days: weekStartDate.weekday - 1)); } @@ -194,45 +197,60 @@ class _SchedulePageState extends State { void _showSemesterPicker() { final info = _schedule.semesterInfo; - if (info == null) return; - final allSemesters = info.allSemesters; - if (allSemesters.isEmpty) return; + if (info == null || info.semesters.isEmpty) return; + + var pendingSemesterId = _schedule.selectedSemesterId; unawaited( showModalBottomSheet( context: context, showDragHandle: true, builder: (context) { - return ListView( - shrinkWrap: true, - padding: const EdgeInsets.only(bottom: 16), - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), - child: Text( - '选择学期', - style: Theme.of(context).textTheme.titleMedium, - ), - ), - for (final entry in allSemesters) - ListTile( - leading: Radio( - value: entry.key, - groupValue: _schedule.selectedSemesterId, - onChanged: (value) { - Navigator.pop(context); - if (value != null) { - unawaited(_schedule.selectSemester(value)); - } + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Row( + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + Expanded( + child: Text( + '选择学期', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + final value = pendingSemesterId; + if (value != null) { + unawaited(_schedule.selectSemester(value)); + } + }, + child: const Text('确定'), + ), + ], + ), + ), + _SemesterWheelPicker( + info: info, + initialSemesterId: _schedule.selectedSemesterId, + onSelectionChanged: (semesterId) { + pendingSemesterId = semesterId; }, ), - title: Text(entry.value), - onTap: () { - Navigator.pop(context); - unawaited(_schedule.selectSemester(entry.key)); - }, - ), - ], + ], + ), + ), ); }, ), @@ -243,7 +261,9 @@ class _SchedulePageState extends State { // Desktop is handled directly by _DesktopWeekTitleMenu. if (isDesktopLayout(context)) return; - final computedWeek = _schedule.currentWeek().clamp(1, 25).toInt(); + final computedWeek = + _schedule.currentWeek().clamp(1, _schedule.totalWeeks).toInt(); + final isInTerm = _schedule.isTodayInTerm; unawaited( showModalBottomSheet( @@ -254,7 +274,8 @@ class _SchedulePageState extends State { return StatefulBuilder( builder: (context, setModalState) { - final isViewingCurrentWeek = _currentWeek == computedWeek; + final isViewingCurrentWeek = + isInTerm && _currentWeek == computedWeek; void selectWeek(int week) { _setWeek(week); @@ -296,11 +317,12 @@ class _SchedulePageState extends State { Flexible( child: ListView.builder( shrinkWrap: true, - itemCount: 25, + itemCount: _schedule.totalWeeks, itemBuilder: (context, index) { final week = index + 1; final selected = week == _currentWeek; - final isActualCurrentWeek = week == computedWeek; + final isActualCurrentWeek = + isInTerm && week == computedWeek; return ListTile( selected: selected, @@ -553,8 +575,10 @@ class _SchedulePageState extends State { final sp = ServiceProvider.of(context); final auth = sp.authService; final tpAuth = sp.thirdPartyAuthService; - final actualCurrentWeek = _schedule.currentWeek().clamp(1, 25).toInt(); - final isViewingCurrentWeek = _currentWeek == actualCurrentWeek; + final isInTerm = _schedule.isTodayInTerm; + final actualCurrentWeek = + _schedule.currentWeek().clamp(1, _schedule.totalWeeks).toInt(); + final isViewingCurrentWeek = isInTerm && _currentWeek == actualCurrentWeek; final useIosChrome = isIos(); final useLegacyIosChrome = usesLegacyIosChrome(); @@ -571,19 +595,10 @@ class _SchedulePageState extends State { sfSymbol: 'ellipsis', accessibilityLabel: '视图设置', menuItems: [ - IosNativeNavigationBarMenuItem( + const IosNativeNavigationBarMenuItem( value: 'semester', title: '切换学期', - children: [ - for (final entry - in _schedule.semesterInfo?.allSemesters ?? - const >[]) - IosNativeNavigationBarMenuItem( - value: 'semester:${entry.key}', - title: entry.value, - checked: _schedule.selectedSemesterId == entry.key, - ), - ], + sfSymbol: 'calendar', ), IosNativeNavigationBarMenuItem( value: 'saturday', @@ -619,7 +634,7 @@ class _SchedulePageState extends State { IosNativeNavigationBarItem( id: 'currentWeek', sfSymbol: 'calendar.badge.clock', - hidden: isViewingCurrentWeek, + hidden: !isInTerm || isViewingCurrentWeek, accessibilityLabel: '回到本周', placementGroup: 'week-actions', ), @@ -641,6 +656,7 @@ class _SchedulePageState extends State { currentWeek: _currentWeek, semesterLabel: _semesterLabel, slideDirection: _slideDirection, + totalWeeks: _schedule.totalWeeks, onWeekChanged: _setWeek, ) else @@ -654,7 +670,9 @@ class _SchedulePageState extends State { trailingIcon: Icons.unfold_more, ), ), - if (isDesktopLayout(context) && !isViewingCurrentWeek) ...[ + if (isDesktopLayout(context) && + isInTerm && + !isViewingCurrentWeek) ...[ const SizedBox(width: 12), TextButton.icon( onPressed: _goToCurrentWeek, @@ -754,7 +772,7 @@ class _SchedulePageState extends State { child: useIosChrome ? PageView.builder( controller: _weekPageController, - itemCount: 25, + itemCount: _schedule.totalWeeks, onPageChanged: (index) { _setCurrentWeek(index + 1); }, @@ -844,14 +862,6 @@ class _SchedulePageState extends State { } void _onMenuSelected(String value) { - if (value.startsWith('semester:')) { - final semesterId = value.substring('semester:'.length); - if (semesterId.isNotEmpty) { - unawaited(_schedule.selectSemester(semesterId)); - } - return; - } - switch (value) { case 'currentWeek': _goToCurrentWeek(); @@ -871,6 +881,64 @@ class _SchedulePageState extends State { } } + void _showDesktopSemesterWheelPopover() { + final anchorContext = _viewSettingsAnchorKey.currentContext; + final info = _schedule.semesterInfo; + if (anchorContext == null || info == null || info.semesters.isEmpty) { + return; + } + + var pendingSemesterId = _schedule.selectedSemesterId; + + showDesktopPopover( + anchorContext: anchorContext, + width: 280, + placement: DesktopPopoverPlacement.belowEnd, + offset: const Offset(-12, 8), + builder: (context, close) { + final theme = Theme.of(context); + return DesktopPopoverSurface( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 4, 4), + child: Text('选择学期', style: theme.textTheme.titleSmall), + ), + _SemesterWheelPicker( + info: info, + initialSemesterId: _schedule.selectedSemesterId, + onSelectionChanged: (semesterId) { + pendingSemesterId = semesterId; + }, + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: close, + child: const Text('取消'), + ), + FilledButton( + onPressed: () { + close(); + final value = pendingSemesterId; + if (value != null) { + unawaited(_schedule.selectSemester(value)); + } + }, + child: const Text('确定'), + ), + ], + ), + ], + ), + ); + }, + ); + } + void _showViewSettingsMenu() { final anchorContext = _viewSettingsAnchorKey.currentContext; if (anchorContext == null) return; @@ -900,11 +968,11 @@ class _SchedulePageState extends State { ), const Divider(height: 1), _DesktopSemesterSelectButton( - semesters: _schedule.semesterInfo?.allSemesters ?? const [], - selectedSemesterId: _schedule.selectedSemesterId, - onChanged: (semesterId) { + hasSemesters: + _schedule.semesterInfo?.semesters.isNotEmpty ?? false, + onTap: () { close(); - unawaited(_schedule.selectSemester(semesterId)); + _showDesktopSemesterWheelPopover(); }, ), const Divider(height: 1), @@ -964,59 +1032,184 @@ class _SchedulePageState extends State { } class _DesktopSemesterSelectButton extends StatelessWidget { - final List> semesters; - final String? selectedSemesterId; - final ValueChanged onChanged; + final bool hasSemesters; + final VoidCallback onTap; const _DesktopSemesterSelectButton({ - required this.semesters, - required this.selectedSemesterId, - required this.onChanged, + required this.hasSemesters, + required this.onTap, }); @override Widget build(BuildContext context) { - final selectedId = selectedSemesterId; - final currentValue = - selectedId != null && semesters.any((entry) => entry.key == selectedId) - ? selectedId - : (semesters.isNotEmpty ? semesters.first.key : ''); - - if (semesters.isEmpty || currentValue.isEmpty) { - return DesktopMenuRow( - leading: const Icon(Icons.swap_horiz, size: 20), - title: Text('切换学期', style: Theme.of(context).textTheme.bodyMedium), + return DesktopMenuRow( + leading: const Icon(Icons.swap_horiz, size: 20), + title: Text('切换学期', style: Theme.of(context).textTheme.bodyMedium), + onTap: hasSemesters ? onTap : null, + ); + } +} + +/// Two synced scroll wheels (academic year, then term) for picking a semester. +/// Reports the pending selection live via [onSelectionChanged]; the caller is +/// responsible for confirming (or discarding) it. +class _SemesterWheelPicker extends StatefulWidget { + final SemesterInfo info; + final String? initialSemesterId; + final ValueChanged onSelectionChanged; + + const _SemesterWheelPicker({ + required this.info, + required this.initialSemesterId, + required this.onSelectionChanged, + }); + + @override + State<_SemesterWheelPicker> createState() => _SemesterWheelPickerState(); +} + +class _SemesterWheelPickerState extends State<_SemesterWheelPicker> { + late final List _years; + late FixedExtentScrollController _yearController; + late FixedExtentScrollController _termController; + late List> _termsForYear; + late int _yearIndex; + late int _termIndex; + + @override + void initState() { + super.initState(); + _years = widget.info.semesters.keys.toList()..sort(); + + var yearIndex = 0; + String? initialLabel; + final initialId = widget.initialSemesterId; + if (initialId != null) { + for (var i = 0; i < _years.length; i++) { + for (final entry in widget.info.semesters[_years[i]]!.entries) { + if (entry.value == initialId) { + yearIndex = i; + initialLabel = entry.key; + } + } + } + } + + _yearIndex = yearIndex; + _termsForYear = _orderedTerms(_years[_yearIndex]); + final labelIndex = initialLabel == null + ? -1 + : _termsForYear.indexWhere((entry) => entry.key == initialLabel); + _termIndex = labelIndex < 0 ? 0 : labelIndex; + + _yearController = FixedExtentScrollController(initialItem: _yearIndex); + _termController = FixedExtentScrollController(initialItem: _termIndex); + } + + @override + void dispose() { + _yearController.dispose(); + _termController.dispose(); + super.dispose(); + } + + List> _orderedTerms(String year) { + final terms = widget.info.semesters[year] ?? const {}; + final entries = terms.entries.toList() + ..sort( + (a, b) => + semesterTermRank(a.key).compareTo(semesterTermRank(b.key)), + ); + return entries; + } + + void _reportSelection() { + if (_termIndex >= _termsForYear.length) return; + widget.onSelectionChanged(_termsForYear[_termIndex].value); + } + + void _onYearChanged(int index) { + final previousLabel = _termIndex < _termsForYear.length + ? _termsForYear[_termIndex].key + : null; + + setState(() { + _yearIndex = index; + _termsForYear = _orderedTerms(_years[_yearIndex]); + final labelIndex = previousLabel == null + ? -1 + : _termsForYear.indexWhere((entry) => entry.key == previousLabel); + _termIndex = labelIndex < 0 + ? 0 + : labelIndex.clamp(0, _termsForYear.length - 1); + }); + _termController.jumpToItem(_termIndex); + _reportSelection(); + } + + void _onTermChanged(int index) { + setState(() => _termIndex = index); + _reportSelection(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + const itemExtent = 40.0; + + Widget wheel({ + required FixedExtentScrollController controller, + required int itemCount, + required String Function(int index) labelBuilder, + required ValueChanged onChanged, + }) { + return CupertinoPicker( + scrollController: controller, + itemExtent: itemExtent, + onSelectedItemChanged: onChanged, + selectionOverlay: Container( + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8), + ), + ), + children: [ + for (var i = 0; i < itemCount; i++) + Center( + child: Text( + labelBuilder(i), + style: theme.textTheme.bodyLarge, + ), + ), + ], ); } - return DesktopSelectPopover( - items: semesters.map((entry) => entry.key).toList(growable: false), - value: currentValue, - onChanged: onChanged, - labelBuilder: (semesterId) { - return semesters.firstWhere((entry) => entry.key == semesterId).value; - }, - leadingBuilder: (context, item, selected) { - return Icon( - selected - ? Icons.radio_button_checked_rounded - : Icons.radio_button_unchecked_rounded, - size: 22, - color: selected - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurfaceVariant, - ); - }, - width: 280, - itemHeight: 56, - visibleItemCount: 5, - anchorBuilder: (context, isOpen, toggle) { - return DesktopMenuRow( - leading: const Icon(Icons.swap_horiz, size: 20), - title: Text('切换学期', style: Theme.of(context).textTheme.bodyMedium), - onTap: toggle, - ); - }, + return SizedBox( + height: 200, + child: Row( + children: [ + Expanded( + flex: 3, + child: wheel( + controller: _yearController, + itemCount: _years.length, + labelBuilder: (i) => _years[i], + onChanged: _onYearChanged, + ), + ), + Expanded( + flex: 2, + child: wheel( + controller: _termController, + itemCount: _termsForYear.length, + labelBuilder: (i) => + '${semesterTermDisplayName(_termsForYear[i].key)}学期', + onChanged: _onTermChanged, + ), + ), + ], + ), ); } } @@ -1025,21 +1218,21 @@ class _DesktopWeekTitleMenu extends StatelessWidget { final int currentWeek; final String semesterLabel; final int slideDirection; + final int totalWeeks; final ValueChanged onWeekChanged; const _DesktopWeekTitleMenu({ required this.currentWeek, required this.semesterLabel, required this.slideDirection, + required this.totalWeeks, required this.onWeekChanged, }); - static final List _weeks = List.generate(25, (index) => index + 1); - @override Widget build(BuildContext context) { return DesktopSelectPopover( - items: _weeks, + items: List.generate(totalWeeks, (index) => index + 1), value: currentWeek, onChanged: onWeekChanged, labelBuilder: (week) => '第 $week 周', @@ -1091,24 +1284,13 @@ class _WeekTitleContent extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween( - begin: Offset(0, slideDirection >= 0 ? 0.3 : -0.3), - end: Offset.zero, - ).animate(animation), - child: child, - ), - ); - }, - child: Text( - '第 $currentWeek 周', - key: ValueKey(currentWeek), - style: theme.textTheme.titleMedium, + ReelText( + '第 $currentWeek 周', + style: theme.textTheme.titleMedium, + options: ReelTextOptions( + direction: slideDirection < 0 + ? ReelTextDirection.down + : ReelTextDirection.up, ), ), if (semesterLabel.isNotEmpty) diff --git a/lib/services/schedule_service.dart b/lib/services/schedule_service.dart index 1fc813a..5e3b49d 100644 --- a/lib/services/schedule_service.dart +++ b/lib/services/schedule_service.dart @@ -19,7 +19,7 @@ class ScheduleService extends ChangeNotifier { SemesterInfo? _semesterInfo; CourseTable? _courseTable; - DateTime? _termBegin; + TermCalendar? _termCalendar; String? _selectedSemesterId; bool _loading = false; String? _error; @@ -31,20 +31,45 @@ class ScheduleService extends ChangeNotifier { String get _baseUrl => apiBaseUrl(_storage); + // Fallback used before the school calendar has been fetched at least once. + static const _fallbackTotalWeeks = 25; + SemesterInfo? get semesterInfo => _semesterInfo; CourseTable? get courseTable => _courseTable; - DateTime? get termBegin => _termBegin; + TermCalendar? get termCalendar => _termCalendar; + DateTime? get termBegin => _termCalendar?.termBegin; String? get selectedSemesterId => _selectedSemesterId; bool get loading => _loading; String? get error => _error; + /// Teaching weeks in the selected semester, i.e. the upper bound for week + /// navigation. Falls back to a generous default until the calendar loads. + int get totalWeeks => + (_termCalendar?.allTeachWeeks ?? 0) > 0 + ? _termCalendar!.allTeachWeeks + : _fallbackTotalWeeks; + ScheduleService(this._storage, this._http, AuthService _, this._tpAuth); int currentWeek() { - if (_termBegin == null) return 1; - final diff = DateTime.now().difference(_termBegin!).inDays; + final begin = termBegin; + if (begin == null) return 1; + final diff = DateTime.now().difference(begin).inDays; if (diff < 0) return 1; - return ((diff ~/ 7) + 1).clamp(1, 25).toInt(); + return ((diff ~/ 7) + 1).clamp(1, totalWeeks).toInt(); + } + + /// Whether "today" actually falls within the selected semester — false + /// before the calendar has loaded, before the term begins, or after its + /// last teaching week. Callers should hide "this week"/"today" indicators + /// when this is false, since there is no meaningful "current week". + bool get isTodayInTerm { + final begin = termBegin; + if (begin == null) return false; + final diff = DateTime.now().difference(begin).inDays; + if (diff < 0) return false; + final week = (diff ~/ 7) + 1; + return week <= totalWeeks; } Map _jsonHeaders() => { @@ -69,7 +94,7 @@ class ScheduleService extends ChangeNotifier { if (_selectedSemesterId != null) { _courseTable = _storage.loadCourseTable(_selectedSemesterId!); } - _termBegin = _storage.loadTermBegin(_selectedSemesterId ?? ''); + _termCalendar = _storage.loadTermCalendar(_selectedSemesterId ?? ''); notifyListeners(); } @@ -150,8 +175,12 @@ class ScheduleService extends ChangeNotifier { if (semEntry.value == semesterId) { // yearEntry.key is like "2024-2025" year = yearEntry.key.split('-').first; - // Map label to number - semNum = semEntry.key.contains('春') ? '1' : '2'; + // rank 0/1/2 (秋/春/暑) -> the term number the backend expects (1/2/3); + // unrecognized term keys fall back to spring (2). + final rank = semesterTermRank(semEntry.key); + semNum = rank < kSemesterTermNames.length + ? (rank + 1).toString() + : '2'; break; } } @@ -179,11 +208,8 @@ class ScheduleService extends ChangeNotifier { throw Exception(data['error'] as String? ?? 'Failed to fetch term begin'); } - final dateStr = data['data'] as String; - _termBegin = DateTime.tryParse(dateStr); - if (_termBegin != null) { - await _storage.saveTermBegin(cacheKey, _termBegin!); - } + _termCalendar = TermCalendar.fromJson(data['data'] as Map); + await _storage.saveTermCalendar(cacheKey, _termCalendar!); notifyListeners(); } @@ -195,7 +221,7 @@ class ScheduleService extends ChangeNotifier { // Load cached data for the new semester so the UI updates instantly. _courseTable = _storage.loadCourseTable(semesterId); - _termBegin = _storage.loadTermBegin(semesterId); + _termCalendar = _storage.loadTermCalendar(semesterId); // Notify the cached-swap UI update. AssignmentService._onScheduleChanged // sees the semester changed and would fire fetchAssignments here — but // that would race our own fetchCourseTable below (both hit diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 7a8d898..1440da6 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -218,13 +218,17 @@ class StorageService { return CourseTable.fromJson(jsonDecode(raw) as Map); } - Future saveTermBegin(String key, DateTime date) => - _prefs.setString('$_termBeginPrefix$key', date.toIso8601String()); + Future saveTermCalendar(String key, TermCalendar info) => _prefs + .setString('$_termBeginPrefix$key', jsonEncode(info.toJson())); - DateTime? loadTermBegin(String key) { + TermCalendar? loadTermCalendar(String key) { final raw = _prefs.getString('$_termBeginPrefix$key'); if (raw == null) return null; - return DateTime.tryParse(raw); + try { + return TermCalendar.fromJson(jsonDecode(raw) as Map); + } catch (_) { + return null; + } } String? get selectedSemester => _prefs.getString(_selectedSemesterKey); diff --git a/pubspec.lock b/pubspec.lock index 27b8da6..ef1a5c5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -531,6 +531,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.5.0" + reel_text: + dependency: "direct main" + description: + name: reel_text + sha256: b129082573b97409d6ae8f2071ab662ada1ebd7491b9b9edf0e3667d55156b65 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.4.3" shared_preferences: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index edb1919..9d3f072 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,6 +49,7 @@ dependencies: open_filex: ^4.5.0 package_info_plus: ^8.0.2 path_provider: ^2.1.5 + reel_text: ^0.4.3 shared_preferences: ^2.3.0 url_launcher: ^6.3.0 web: ^1.1.1