Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/ui-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ Android 端保存这些设置,在应用关闭后刷新时读取当前系统颜
直接显示课程名、已有建筑或教室与精确节次;地点为空时不显示占位文案,也不再叠加
重复的悬浮详情或强调色侧边条。

日程列表以顶部的“未完成/已完成”分段选择切换内容,不同时使用横滑切页。
日程列表以顶部的“未完成/已完成”分段选择切换内容,不同时使用横滑切页;
时间轴不显示完成状态选择。学期切换、列表/时间轴切换和已删除 DDL 入口集中在
页头的“更多”菜单,标题行只保留类型筛选,避免手机端并排图标难以点击。
条目横拖立即跟手:短滑露出可点击的操作按钮,长滑达到阈值后振动并显示松手提示。
左滑删除,右滑完成或恢复 DDL;松手前可以拖回取消。展开后再次拖动从当前位置继续,
点击条目先收起,点击外部、滚动或开始操作另一条也会收起。纵向和斜向拖动优先交给滚动,
Expand Down
32 changes: 32 additions & 0 deletions lib/agenda/period.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import '../schedule/model.dart';
import 'model.dart';

bool agendaEntryMatchesTerm(
AgendaEntry entry,
String term,
Iterable<SemesterSchedule> 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<AgendaEntry> filterAgendaByTerm(
Iterable<AgendaEntry> entries,
String? term,
Iterable<SemesterSchedule> schedules,
) => term == null
? entries.toList()
: entries
.where((entry) => agendaEntryMatchesTerm(entry, term, schedules))
.toList();
154 changes: 124 additions & 30 deletions lib/agenda/ui/calendar_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -35,11 +38,21 @@ class CalendarScreen extends StatefulWidget {
class _CalendarScreenState extends State<CalendarScreen> {
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([
Expand All @@ -55,10 +68,29 @@ class _CalendarScreenState extends State<CalendarScreen> {
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 = <String>{
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 =
Expand All @@ -78,7 +110,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
],
);

Widget statusHeader() => Column(
Widget listHeader() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
Expand Down Expand Up @@ -125,47 +157,38 @@ class _CalendarScreenState extends State<CalendarScreen> {
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,
Expand Down Expand Up @@ -197,6 +220,77 @@ class _CalendarScreenState extends State<CalendarScreen> {
}
}

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<String> terms;
final String? currentTerm;
final String? selectedTerm;
final ValueChanged<String?> 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 = <String?>[...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,
Expand Down
2 changes: 2 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -861,6 +862,7 @@
},
"syncAll": "Sync all",
"customSync": "Custom sync",
"syncTerm": "Semester to sync",
"syncSchoolData": "School data",
"syncDeadlineData": "DDL sources",
"syncTooltip": "Sync",
Expand Down
12 changes: 12 additions & 0 deletions lib/l10n/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions lib/l10n/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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';

Expand Down
8 changes: 7 additions & 1 deletion lib/l10n/app_localizations_zh.dart
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,7 @@ class AppLocalizationsZh extends AppLocalizations {

@override
String monthHeader(String month) {
return month;
return '$month';
}

@override
Expand Down Expand Up @@ -720,6 +720,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get agendaType => '日程类型';

@override
String get agendaPeriod => '日程时段';

@override
String agendaCount(int count) {
return '$count项日程';
Expand Down Expand Up @@ -1043,6 +1046,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get customSync => '自定义同步';

@override
String get syncTerm => '同步学期';

@override
String get syncSchoolData => '学校数据';

Expand Down
2 changes: 2 additions & 0 deletions lib/l10n/app_zh.arb
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@
"agendaNoUpcoming": "没有即将到来的日程",
"agendaNoPending": "没有未完成的日程",
"agendaType": "日程类型",
"agendaPeriod": "日程时段",
"agendaCount": "{count}项日程",
"agendaNoPendingShort": "没有未完成的日程",
"agendaCompletedCount": "已完成 {count}",
Expand Down Expand Up @@ -292,6 +293,7 @@
"timeRange": "{start}–{end}",
"syncAll": "同步全部",
"customSync": "自定义同步",
"syncTerm": "同步学期",
"syncSchoolData": "学校数据",
"syncDeadlineData": "DDL 来源",
"syncTooltip": "同步",
Expand Down
Loading
Loading