diff --git a/docs/ui-design.md b/docs/ui-design.md index a31f1d5..b66d32b 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -127,7 +127,9 @@ Android 端保存这些设置,在应用关闭后刷新时读取当前系统颜 直接显示课程名、已有建筑或教室与精确节次;地点为空时不显示占位文案,也不再叠加 重复的悬浮详情或强调色侧边条。 -日程列表以顶部的“未完成/已完成”分段选择切换内容,不同时使用横滑切页。 +日程列表以顶部的“未完成/已完成”分段选择切换内容,不同时使用横滑切页; +时间轴不显示完成状态选择。学期切换、列表/时间轴切换和已删除 DDL 入口集中在 +页头的“更多”菜单,标题行只保留类型筛选,避免手机端并排图标难以点击。 条目横拖立即跟手:短滑露出可点击的操作按钮,长滑达到阈值后振动并显示松手提示。 左滑删除,右滑完成或恢复 DDL;松手前可以拖回取消。展开后再次拖动从当前位置继续, 点击条目先收起,点击外部、滚动或开始操作另一条也会收起。纵向和斜向拖动优先交给滚动, diff --git a/lib/agenda/period.dart b/lib/agenda/period.dart new file mode 100644 index 0000000..438b5ad --- /dev/null +++ b/lib/agenda/period.dart @@ -0,0 +1,32 @@ +import '../schedule/model.dart'; +import 'model.dart'; + +bool agendaEntryMatchesTerm( + AgendaEntry entry, + String term, + Iterable schedules, +) { + final explicitTerm = entry.term?.trim(); + if (explicitTerm != null && explicitTerm.isNotEmpty) { + return explicitTerm == term; + } + + for (final schedule in schedules) { + if (schedule.term != term) continue; + final week = schedule.weekAt(entry.startsAt); + if (week >= schedule.firstWeek && week <= schedule.lastWeek) { + return true; + } + } + return false; +} + +List filterAgendaByTerm( + Iterable entries, + String? term, + Iterable schedules, +) => term == null + ? entries.toList() + : entries + .where((entry) => agendaEntryMatchesTerm(entry, term, schedules)) + .toList(); diff --git a/lib/agenda/ui/calendar_screen.dart b/lib/agenda/ui/calendar_screen.dart index 5ba2eb6..9d0e2f8 100644 --- a/lib/agenda/ui/calendar_screen.dart +++ b/lib/agenda/ui/calendar_screen.dart @@ -5,12 +5,15 @@ import '../../app/ui/add_menu.dart'; import '../../core/time.dart'; import '../../l10n/formats.dart'; import '../../l10n/l10n.dart'; +import '../../schedule/model.dart'; import '../../sync/ui/sync_button.dart'; import '../../ui/app_menu_anchor.dart'; import '../../ui/app_menu_trigger.dart'; import '../../ui/app_page.dart'; import '../../ui/pull_to_reveal_list.dart'; +import '../../ui/semester_dropdown.dart'; import '../model.dart'; +import '../period.dart'; import '../view_mode.dart'; import 'agenda_list_tile.dart'; import 'agenda_timeline.dart'; @@ -35,11 +38,21 @@ class CalendarScreen extends StatefulWidget { class _CalendarScreenState extends State { AgendaKind? _kind; int _status = 0; + String? _term; + bool _termInitialized = false; void _setStatus(int index) { if (_status != index) setState(() => _status = index); } + void _setTerm(String? term) { + if (_termInitialized && _term == term) return; + setState(() { + _termInitialized = true; + _term = term; + }); + } + @override Widget build(BuildContext context) => ListenableBuilder( listenable: Listenable.merge([ @@ -55,10 +68,29 @@ class _CalendarScreenState extends State { final l10n = context.l10n; final controller = widget.controller; final now = widget.now; - final scheme = Theme.of(context).colorScheme; - final entries = controller.agenda.entries - .where((entry) => _kind == null || entry.kind == _kind) - .toList(); + final schedules = controller.timetable.schedules.toList(); + final allEntries = controller.agenda.entries; + final terms = { + for (final schedule in schedules) schedule.term, + for (final entry in allEntries) + if (entry.term?.trim() case final term? when term.isNotEmpty) term, + }.toList(); + final currentTerm = semesterAt(schedules, now)?.term; + final orderedTerms = orderedSemesterTerms(terms, currentTerm: currentTerm); + final defaultTerm = + currentTerm ?? + controller.timetable.schedule?.term ?? + orderedTerms.firstOrNull; + final selectedTerm = !_termInitialized + ? defaultTerm + : _term == null || terms.contains(_term) + ? _term + : null; + final entries = filterAgendaByTerm( + allEntries, + selectedTerm, + schedules, + ).where((entry) => _kind == null || entry.kind == _kind).toList(); final doneCount = entries.where((entry) => entry.isFinished(now)).length; final deletedDeadlineCount = controller.agenda.deletedDeadlines.length; final timeline = @@ -78,7 +110,7 @@ class _CalendarScreenState extends State { ], ); - Widget statusHeader() => Column( + Widget listHeader() => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( @@ -125,47 +157,38 @@ class _CalendarScreenState extends State { actions: [ AddButton(controller: controller), SyncButton(controller: controller), - if (deletedDeadlineCount > 0) - IconButton( - tooltip: l10n.deletedDeadlinesTitle, - onPressed: controller.agenda.busy - ? null - : () => showDeletedDeadlines(context, controller.agenda), - icon: Badge.count( - count: deletedDeadlineCount, - backgroundColor: scheme.primary, - textColor: scheme.onPrimary, - child: const Icon(Icons.restore_from_trash_outlined), - ), - ), - IconButton( - tooltip: timeline - ? l10n.agendaSwitchToList - : l10n.agendaSwitchToTimeline, - icon: Icon( - timeline ? Icons.view_list_outlined : Icons.view_timeline_outlined, - ), - onPressed: () => controller.settings.setAgendaViewMode( + _AgendaOverflowMenu( + terms: orderedTerms, + currentTerm: currentTerm, + selectedTerm: selectedTerm, + onTermChanged: _setTerm, + timeline: timeline, + deletedDeadlineCount: deletedDeadlineCount, + deletedDeadlinesEnabled: + !controller.agenda.busy && controller.agenda.ready, + onViewModeChanged: () => controller.settings.setAgendaViewMode( timeline ? AgendaViewMode.list : AgendaViewMode.timeline, ), + onOpenDeletedDeadlines: () => + showDeletedDeadlines(context, controller.agenda), ), ], - // Keep browsing positions while changing presentation or completion - // filter. Horizontal gestures belong to rows or timeline navigation. + // Keep browsing positions while changing presentation. Horizontal + // gestures belong to rows or timeline navigation. child: IndexedStack( index: timeline ? 1 : 0, children: [ Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - statusHeader(), + listHeader(), Expanded( child: IndexedStack( index: _status, children: [ for (var index = 0; index < 2; index++) _AgendaStatusList( - key: ValueKey((_kind, index)), + key: ValueKey((_kind, selectedTerm, index)), controller: controller, entries: entries, completed: index == 1, @@ -197,6 +220,77 @@ class _CalendarScreenState extends State { } } +class _AgendaOverflowMenu extends StatelessWidget { + const _AgendaOverflowMenu({ + required this.terms, + required this.currentTerm, + required this.selectedTerm, + required this.onTermChanged, + required this.timeline, + required this.deletedDeadlineCount, + required this.deletedDeadlinesEnabled, + required this.onViewModeChanged, + required this.onOpenDeletedDeadlines, + }); + + final List terms; + final String? currentTerm; + final String? selectedTerm; + final ValueChanged onTermChanged; + final bool timeline; + final int deletedDeadlineCount; + final bool deletedDeadlinesEnabled; + final VoidCallback onViewModeChanged; + final VoidCallback onOpenDeletedDeadlines; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final orderedTerms = orderedSemesterTerms(terms, currentTerm: currentTerm); + final values = [...orderedTerms, null]; + + return AppMenuAnchor( + menuChildren: [ + AppMenuItemButton( + leadingIcon: Icon( + timeline ? Icons.view_list_outlined : Icons.view_timeline_outlined, + ), + onPressed: onViewModeChanged, + child: Text( + timeline ? l10n.agendaSwitchToList : l10n.agendaSwitchToTimeline, + ), + ), + if (deletedDeadlineCount > 0) + AppMenuItemButton( + leadingIcon: const Icon(Icons.restore_from_trash_outlined), + trailingIcon: Text('$deletedDeadlineCount'), + onPressed: deletedDeadlinesEnabled ? onOpenDeletedDeadlines : null, + child: Text(l10n.deletedDeadlinesTitle), + ), + const Divider(height: 9), + for (final term in values) + Semantics( + selected: selectedTerm == term, + child: AppMenuItemButton( + onPressed: () => onTermChanged(term), + trailingIcon: selectedTerm == term + ? const Icon(Icons.check_rounded, size: 18) + : const SizedBox(width: 18), + child: Text( + term == null ? l10n.allSemesters : localizedTerm(l10n, term), + ), + ), + ), + ], + builder: (context, menu, isOpen) => IconButton( + tooltip: l10n.more, + onPressed: () => isOpen ? menu.close() : menu.open(), + icon: const Icon(Icons.more_horiz_rounded), + ), + ); + } +} + class _AgendaStatusList extends StatelessWidget { const _AgendaStatusList({ super.key, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e08e4a8..635fdf3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -548,6 +548,7 @@ "agendaNoUpcoming": "No upcoming agenda items", "agendaNoPending": "No pending agenda items", "agendaType": "Agenda type", + "agendaPeriod": "Agenda period", "agendaCount": "{count, plural, =1{1 agenda item} other{{count} agenda items}}", "@agendaCount": { "placeholders": { @@ -861,6 +862,7 @@ }, "syncAll": "Sync all", "customSync": "Custom sync", + "syncTerm": "Semester to sync", "syncSchoolData": "School data", "syncDeadlineData": "DDL sources", "syncTooltip": "Sync", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2bc4320..9ca281e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1349,6 +1349,12 @@ abstract class AppLocalizations { /// **'Agenda type'** String get agendaType; + /// No description provided for @agendaPeriod. + /// + /// In en, this message translates to: + /// **'Agenda period'** + String get agendaPeriod; + /// No description provided for @agendaCount. /// /// In en, this message translates to: @@ -1872,6 +1878,12 @@ abstract class AppLocalizations { /// **'Custom sync'** String get customSync; + /// No description provided for @syncTerm. + /// + /// In en, this message translates to: + /// **'Semester to sync'** + String get syncTerm; + /// No description provided for @syncSchoolData. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c0daf19..8ac22c8 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -758,6 +758,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get agendaType => 'Agenda type'; + @override + String get agendaPeriod => 'Agenda period'; + @override String agendaCount(int count) { String _temp0 = intl.Intl.pluralLogic( @@ -1107,6 +1110,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get customSync => 'Custom sync'; + @override + String get syncTerm => 'Semester to sync'; + @override String get syncSchoolData => 'School data'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index b1805f2..9be1e40 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -612,7 +612,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String monthHeader(String month) { - return month; + return '$month'; } @override @@ -720,6 +720,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get agendaType => '日程类型'; + @override + String get agendaPeriod => '日程时段'; + @override String agendaCount(int count) { return '$count项日程'; @@ -1043,6 +1046,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get customSync => '自定义同步'; + @override + String get syncTerm => '同步学期'; + @override String get syncSchoolData => '学校数据'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 42affc2..ea3ce30 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -206,6 +206,7 @@ "agendaNoUpcoming": "没有即将到来的日程", "agendaNoPending": "没有未完成的日程", "agendaType": "日程类型", + "agendaPeriod": "日程时段", "agendaCount": "{count}项日程", "agendaNoPendingShort": "没有未完成的日程", "agendaCompletedCount": "已完成 {count}", @@ -292,6 +293,7 @@ "timeRange": "{start}–{end}", "syncAll": "同步全部", "customSync": "自定义同步", + "syncTerm": "同步学期", "syncSchoolData": "学校数据", "syncDeadlineData": "DDL 来源", "syncTooltip": "同步", diff --git a/lib/sync/ui/sync.dart b/lib/sync/ui/sync.dart index 8702c20..8efca20 100644 --- a/lib/sync/ui/sync.dart +++ b/lib/sync/ui/sync.dart @@ -3,20 +3,24 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../../app/controller.dart'; +import '../../core/time.dart'; import '../../l10n/formats.dart'; import '../../l10n/l10n.dart'; import '../../mooc/ui/mooc_sheet.dart'; +import '../../schedule/model.dart'; import '../../school/ui/school_screen.dart'; import '../../school/ui/school_verification_dialog.dart'; import '../../ui/app_notice.dart'; import '../../ui/design.dart'; import '../../ui/section_label.dart'; import '../../ui/selection_controls.dart'; +import '../../ui/semester_dropdown.dart'; import '../../ui/sheet_layout.dart'; import '../controller.dart'; import 'sync_text.dart'; final _progressSheets = >{}; +typedef _CustomSyncSelection = ({Set targets, String? term}); /// Starts one round silently. Only account authentication requires a page. Future startSync( @@ -74,12 +78,12 @@ Future startSync( } Future customizeSync(BuildContext context, AppController app) async { - final selected = await showAppSheet>( + final selected = await showAppSheet<_CustomSyncSelection>( context: context, builder: (_) => _CustomSyncSheet(app: app), ); if (selected == null || !context.mounted) return; - await startSync(context, app, targets: selected); + await startSync(context, app, targets: selected.targets, term: selected.term); } Future showSyncProgress(BuildContext context, AppController app) { @@ -104,6 +108,30 @@ class _CustomSyncSheet extends StatefulWidget { class _CustomSyncSheetState extends State<_CustomSyncSheet> { late final _availableTargets = widget.app.sync.bulkTargets; late final _selected = widget.app.sync.defaultTargets; + late final String? _currentTerm; + late final List _terms; + String? _term; + + bool get _usesTerm => + _selected.contains(SyncTarget.schedule) || + _selected.contains(SyncTarget.exams); + + @override + void initState() { + super.initState(); + _currentTerm = semesterAt( + widget.app.timetable.schedules, + campusNow(), + )?.term; + _terms = orderedSemesterTerms( + widget.app.timetable.terms, + currentTerm: _currentTerm, + ); + _term = + _currentTerm ?? + widget.app.timetable.schedule?.term ?? + _terms.firstOrNull; + } @override Widget build(BuildContext context) => SheetLayout( @@ -125,6 +153,18 @@ class _CustomSyncSheetState extends State<_CustomSyncSheet> { ), const SizedBox(height: AppSpacing.md), AppSectionHeader(context.l10n.syncSchoolData), + if (_usesTerm && _terms.isNotEmpty) ...[ + SemesterDropdown( + label: context.l10n.syncTerm, + value: _term, + terms: _terms, + currentTerm: _currentTerm, + onChanged: (term) { + if (term != null) setState(() => _term = term); + }, + ), + const SizedBox(height: AppSpacing.sm), + ], _SyncTargetChoices( targets: _availableTargets.where( (target) => target.category == SyncCategory.schoolData, @@ -168,7 +208,10 @@ class _CustomSyncSheetState extends State<_CustomSyncSheet> { primary: FilledButton( onPressed: _selected.isEmpty ? null - : () => Navigator.pop(context, _selected), + : () => Navigator.pop(context, ( + targets: Set.unmodifiable(_selected), + term: _usesTerm ? _term : null, + )), child: Text(context.l10n.syncSelected), ), ), diff --git a/lib/ui/semester_dropdown.dart b/lib/ui/semester_dropdown.dart new file mode 100644 index 0000000..44107ba --- /dev/null +++ b/lib/ui/semester_dropdown.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +import '../l10n/formats.dart'; +import '../l10n/l10n.dart'; +import 'app_dropdown_field.dart'; +import 'app_menu_anchor.dart'; +import 'app_menu_trigger.dart'; + +List orderedSemesterTerms( + Iterable terms, { + String? currentTerm, +}) { + final unique = { + for (final term in terms) + if (term.trim() case final value when value.isNotEmpty) value, + }; + final current = currentTerm?.trim(); + final remaining = unique.where((term) => term != current).toList() + ..sort((first, second) => second.compareTo(first)); + return [ + if (current != null && unique.contains(current)) current, + ...remaining, + ]; +} + +class SemesterDropdown extends StatelessWidget { + const SemesterDropdown({ + super.key, + required this.label, + required this.value, + required this.terms, + required this.onChanged, + this.currentTerm, + this.includeAll = false, + this.compact = false, + }); + + final String label; + final String? value; + final List terms; + final String? currentTerm; + final ValueChanged onChanged; + final bool includeAll; + final bool compact; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final ordered = orderedSemesterTerms(terms, currentTerm: currentTerm); + final selected = value != null && ordered.contains(value) + ? value + : includeAll + ? null + : ordered.firstOrNull; + + String labelFor(String? term) => + term == null ? l10n.allSemesters : localizedTerm(l10n, term); + + final values = [...ordered, if (includeAll) null]; + if (compact) { + return AppMenuAnchor( + menuChildren: [ + for (final term in values) + Semantics( + selected: selected == term, + child: AppMenuItemButton( + onPressed: () => onChanged(term), + trailingIcon: selected == term + ? const Icon(Icons.check_rounded, size: 18) + : const SizedBox(width: 18), + child: Text(labelFor(term)), + ), + ), + ], + builder: (context, menu, isOpen) => AppMenuTrigger( + label: labelFor(selected), + semanticsLabel: label, + isOpen: isOpen, + onPressed: () => isOpen ? menu.close() : menu.open(), + ), + ); + } + + return AppDropdownField( + label: label, + value: selected, + onChanged: onChanged, + entries: [ + for (final term in values) + DropdownMenuEntry(value: term, label: labelFor(term)), + ], + ); + } +} diff --git a/test/agenda_swipe_test.dart b/test/agenda_swipe_test.dart index c66b789..391783d 100644 --- a/test/agenda_swipe_test.dart +++ b/test/agenda_swipe_test.dart @@ -55,6 +55,17 @@ void main() { return app; } + Future openAgendaMenu(WidgetTester tester) async { + await tester.tap(find.byTooltip('更多')); + await tester.pumpAndSettle(); + } + + Future openDeletedDeadlines(WidgetTester tester) async { + await openAgendaMenu(tester); + await tester.tap(find.text('已删除的 DDL')); + await tester.pumpAndSettle(); + } + testWidgets('A full left swipe deletes and undo restores the entry', ( tester, ) async { @@ -108,16 +119,14 @@ void main() { tester, ) async { final app = await showAgenda(tester, deleted: true); - final restoreButton = find.byTooltip('已删除的 DDL'); - expect(restoreButton, findsOneWidget); - await tester.tap(restoreButton); - await tester.pumpAndSettle(); + await openDeletedDeadlines(tester); expect(find.text('已删除的 DDL'), findsOneWidget); await tester.tap(find.byTooltip('恢复')); await tester.pumpAndSettle(); expect(app.agenda.entries.single.id, 'task'); expect(app.agenda.deletedDeadlines, isEmpty); - expect(restoreButton, findsNothing); + await openAgendaMenu(tester); + expect(find.text('已删除的 DDL'), findsNothing); expect(tester.takeException(), isNull); }); @@ -130,10 +139,8 @@ void main() { viewSize: const Size(320, 640), textScale: 1.8, ); - final restoreButton = find.byTooltip('已删除的 DDL'); - expect(restoreButton.hitTestable(), findsOneWidget); - await tester.tap(restoreButton); - await tester.pumpAndSettle(); + expect(find.byTooltip('更多').hitTestable(), findsOneWidget); + await openDeletedDeadlines(tester); expect(find.text('已删除的 DDL'), findsOneWidget); expect(tester.takeException(), isNull); }, @@ -143,8 +150,7 @@ void main() { 'Deleted DDL recovery supports select all, clear, and partial restore', (tester) async { final app = await showAgenda(tester, count: 3, deleted: true); - await tester.tap(find.byTooltip('已删除的 DDL')); - await tester.pumpAndSettle(); + await openDeletedDeadlines(tester); await tester.tap(find.byType(Checkbox).first); await tester.pump(); @@ -174,8 +180,7 @@ void main() { 'Emptying the recycle bin requires confirmation and permanently deletes DDLs', (tester) async { final app = await showAgenda(tester, count: 3, deleted: true); - await tester.tap(find.byTooltip('已删除的 DDL')); - await tester.pumpAndSettle(); + await openDeletedDeadlines(tester); await tester.tap(find.text('清空回收站')); await tester.pumpAndSettle(); @@ -192,7 +197,8 @@ void main() { expect(app.agenda.deletedDeadlines, isEmpty); expect(find.text('已清空 3 条 DDL'), findsOneWidget); - expect(find.byTooltip('已删除的 DDL'), findsNothing); + await openAgendaMenu(tester); + expect(find.text('已删除的 DDL'), findsNothing); expect(tester.takeException(), isNull); }, ); diff --git a/test/agenda_term_filter_test.dart b/test/agenda_term_filter_test.dart new file mode 100644 index 0000000..d6e0195 --- /dev/null +++ b/test/agenda_term_filter_test.dart @@ -0,0 +1,199 @@ +import 'package:bitree/agenda/model.dart'; +import 'package:bitree/agenda/period.dart'; +import 'package:bitree/agenda/ui/calendar_screen.dart'; +import 'package:bitree/agenda/view_mode.dart'; +import 'package:bitree/l10n/app_localizations_zh.dart'; +import 'package:bitree/l10n/schedule.dart'; +import 'package:bitree/schedule/model.dart'; +import 'package:bitree/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/test_app.dart'; + +final _now = DateTime.utc(2026, 11, 3, 12); +final _currentSchedule = SemesterSchedule( + term: '2026-2027-1', + firstMonday: DateTime.utc(2026, 8, 31), + courses: const [], + firstWeek: 1, + lastWeek: 20, +); +final _pastSchedule = SemesterSchedule( + term: '2025-2026-2', + firstMonday: DateTime.utc(2026, 2, 23), + courses: const [], + firstWeek: 1, + lastWeek: 20, +); + +AgendaEntry _event({ + required String id, + required String title, + required DateTime startsAt, + String? term, +}) => AgendaEntry( + id: id, + title: title, + kind: AgendaKind.event, + startsAt: startsAt, + endsAt: startsAt.add(const Duration(hours: 1)), + term: term, +); + +Future _pumpAgenda( + WidgetTester tester, + List entries, { + Size size = const Size(390, 844), + double scale = 1, + AgendaViewMode viewMode = AgendaViewMode.list, +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final controller = TestAppController( + schedule: _currentSchedule, + schedules: [_currentSchedule, _pastSchedule], + entries: entries, + ); + await controller.initialize(); + await controller.settings.setAgendaViewMode(viewMode); + + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: Scaffold( + body: CalendarScreen(controller: controller, now: _now), + ), + ), + ); + await tester.pumpAndSettle(); +} + +Future _openAgendaMenu(WidgetTester tester) async { + await tester.tap(find.byTooltip(AppLocalizationsZh().more)); + await tester.pumpAndSettle(); +} + +void main() { + test('Entries without a stored term use the saved semester dates', () { + final inferred = _event( + id: 'inferred', + title: '历史学期活动', + startsAt: DateTime.utc(2026, 3, 14, 10), + ); + expect( + agendaEntryMatchesTerm(inferred, _pastSchedule.term, [ + _currentSchedule, + _pastSchedule, + ]), + isTrue, + ); + expect( + agendaEntryMatchesTerm(inferred, _currentSchedule.term, [ + _currentSchedule, + _pastSchedule, + ]), + isFalse, + ); + }); + + testWidgets('The current semester is selected by default and terms switch', ( + tester, + ) async { + final l10n = AppLocalizationsZh(); + final current = _event( + id: 'current', + title: '本学期活动', + startsAt: DateTime.utc(2026, 11, 5, 10), + term: _currentSchedule.term, + ); + final past = _event( + id: 'past', + title: '历史学期活动', + startsAt: DateTime.utc(2026, 11, 5, 11), + term: _pastSchedule.term, + ); + final allOnly = _event( + id: 'all-only', + title: '未归属活动', + startsAt: DateTime.utc(2030, 11, 5, 12), + ); + await _pumpAgenda(tester, [current, past, allOnly]); + + expect(find.byTooltip(l10n.more), findsOneWidget); + expect(find.text(l10n.agendaPendingTab(1)), findsOneWidget); + expect(find.text(current.title), findsWidgets); + expect(find.text(past.title), findsNothing); + expect(find.text(allOnly.title), findsNothing); + + final pastTerm = find.text(localizedTerm(l10n, _pastSchedule.term)); + await _openAgendaMenu(tester); + await tester.tap(pastTerm.last); + await tester.pumpAndSettle(); + expect(find.text(current.title), findsNothing); + expect(find.text(past.title), findsWidgets); + expect(find.text(allOnly.title), findsNothing); + + final allTerms = find.text(l10n.allSemesters); + await _openAgendaMenu(tester); + await tester.tap(allTerms.last); + await tester.pumpAndSettle(); + expect(find.text(current.title), findsWidgets); + expect(find.text(past.title), findsWidgets); + expect(find.text(allOnly.title), findsWidgets); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'The period dropdown remains usable on a narrow large-text layout', + (tester) async { + final l10n = AppLocalizationsZh(); + await _pumpAgenda( + tester, + [ + _event( + id: 'current', + title: '本学期活动', + startsAt: DateTime.utc(2026, 11, 5, 10), + term: _currentSchedule.term, + ), + ], + size: const Size(320, 640), + scale: 1.8, + ); + + expect(find.byTooltip(l10n.more).hitTestable(), findsOneWidget); + await _openAgendaMenu(tester); + expect( + find.text(localizedTerm(l10n, _pastSchedule.term)), + findsOneWidget, + ); + expect(find.text('本学期活动'), findsWidgets); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('Completion status is only shown in list mode', (tester) async { + await _pumpAgenda(tester, [ + _event( + id: 'current', + title: '本学期活动', + startsAt: DateTime.utc(2026, 11, 5, 10), + term: _currentSchedule.term, + ), + ], viewMode: AgendaViewMode.timeline); + + expect(find.byType(SegmentedButton), findsNothing); + await _openAgendaMenu(tester); + expect(find.text(AppLocalizationsZh().agendaSwitchToList), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/sync_test.dart b/test/sync_test.dart index 303870f..ff9360b 100644 --- a/test/sync_test.dart +++ b/test/sync_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:bitree/agenda/controller.dart'; import 'package:bitree/agenda/model.dart'; import 'package:bitree/core/async/cancelable_request.dart'; +import 'package:bitree/core/time.dart'; import 'package:bitree/l10n/app_localizations_zh.dart'; import 'package:bitree/mooc/credentials.dart'; import 'package:bitree/schedule/model.dart'; @@ -16,6 +17,7 @@ import 'package:bitree/sync/ui/sync.dart'; import 'package:bitree/sync/ui/sync_button.dart'; import 'package:bitree/sync/ui/sync_text.dart'; import 'package:bitree/ui/selection_controls.dart'; +import 'package:bitree/ui/semester_dropdown.dart'; import 'package:bitree/ui/theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -28,6 +30,7 @@ class _SchoolService extends SchoolService { final began = Completer(); final release = Completer(); final reads = []; + final requestedTerms = []; bool smsRequired = false; @override Future<({SemesterSchedule schedule, List terms})> synchronize( @@ -36,6 +39,7 @@ class _SchoolService extends SchoolService { String? term, }) async { reads.add(SyncTarget.schedule); + requestedTerms.add(term); if (!began.isCompleted) began.complete(); await release.future; if (smsRequired) { @@ -69,6 +73,7 @@ class _SchoolService extends SchoolService { String? term, }) async { reads.add(SyncTarget.exams); + requestedTerms.add(term); return (term: term ?? '2026-2027-1', exams: []); } } @@ -124,30 +129,36 @@ void main() { await server.close(force: true); }); - TestAppController app(_SchoolService service, {bool mooc = false}) => - TestAppController( - school: service, - schoolStorage: MemorySchoolStorage( - credentials: const SchoolCredentials( - username: 'student', - password: 'test', - ), - ), - moocStorage: mooc - ? (MemoryMoocStorage() - ..account = MoocAccount( - id: '8', - name: '测试账号', - credentials: MoocCredentials( - userAgent: 'test', - cookies: [ - Cookie('NTESSTUDYSI', 'csrf'), - Cookie('STUDY_SESS', 'session'), - ], - ), - )) - : null, - ); + TestAppController app( + _SchoolService service, { + bool mooc = false, + SemesterSchedule? schedule, + List schedules = const [], + }) => TestAppController( + school: service, + schedule: schedule, + schedules: schedules, + schoolStorage: MemorySchoolStorage( + credentials: const SchoolCredentials( + username: 'student', + password: 'test', + ), + ), + moocStorage: mooc + ? (MemoryMoocStorage() + ..account = MoocAccount( + id: '8', + name: '测试账号', + credentials: MoocCredentials( + userAgent: 'test', + cookies: [ + Cookie('NTESSTUDYSI', 'csrf'), + Cookie('STUDY_SESS', 'session'), + ], + ), + )) + : null, + ); test('Sync targets keep session dependencies separate from UI groups', () { expect( @@ -462,6 +473,53 @@ void main() { }, ); + testWidgets('Custom sync sends the selected semester to school data', ( + tester, + ) async { + final currentMonday = mondayOf(campusNow()); + final current = SemesterSchedule( + term: '2026-2027-1', + firstMonday: currentMonday, + courses: const [], + firstWeek: 1, + lastWeek: 20, + ); + final historical = SemesterSchedule( + term: '2025-2026-2', + firstMonday: currentMonday.subtract(const Duration(days: 7 * 26)), + courses: const [], + firstWeek: 1, + lastWeek: 20, + ); + final service = _SchoolService()..release.complete(); + final model = app( + service, + schedule: current, + schedules: [current, historical], + ); + await model.initialize(); + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold(body: SyncButton(controller: model)), + ), + ); + + await openCustom(tester); + await tester.tap(find.widgetWithText(AppSelectionTile, '课表')); + await tester.pump(); + await tester.tap(find.byType(SemesterDropdown)); + await tester.pumpAndSettle(); + await tester.tap(find.text('2025–2026 春季学期').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('同步所选内容')); + await waitFor(tester, () => !model.sync.busy); + + expect(service.requestedTerms, ['2025-2026-2']); + expect(model.timetable.schedule!.term, historical.term); + expect(tester.takeException(), isNull); + }); + testWidgets( 'Custom sync stays active after progress is collapsed and the page changes', (tester) async {