From 2e806e357d8f971af4bc9bc6d199ad21a6af114e Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Mon, 9 Nov 2026 12:04:06 +0800 Subject: [PATCH 01/24] feat(agenda): add daily deadline cutoff window --- docs/deadline-cutoff.md | 50 +++ lib/agenda/calendar.dart | 2 +- lib/agenda/controller.dart | 16 +- lib/agenda/deadline_cutoff.dart | 77 ++++ lib/agenda/model.dart | 40 +- lib/agenda/timeline_layout.dart | 10 +- lib/agenda/ui/adjusted_deadline_painter.dart | 162 ++++++++ lib/agenda/ui/agenda_detail.dart | 40 +- lib/agenda/ui/agenda_list_tile.dart | 6 +- lib/agenda/ui/agenda_timeline.dart | 51 ++- lib/agenda/ui/calendar_list.dart | 2 +- lib/agenda/ui/calendar_screen.dart | 4 +- lib/agenda/ui/deleted_deadlines.dart | 4 +- lib/agenda/ui/timeline_entry.dart | 39 +- lib/app/controller.dart | 6 + lib/app/settings_controller.dart | 29 ++ lib/app/ui/deadline_cutoff_settings.dart | 135 +++++++ lib/app/ui/settings_screen.dart | 2 + lib/l10n/app_en.arb | 56 +++ lib/l10n/app_localizations.dart | 90 +++++ lib/l10n/app_localizations_en.dart | 55 +++ lib/l10n/app_localizations_zh.dart | 55 +++ lib/l10n/app_zh.arb | 15 + lib/l10n/formats.dart | 8 +- test/agenda_adjusted_timeline_test.dart | 392 +++++++++++++++++++ test/deadline_cutoff_integration_test.dart | 288 ++++++++++++++ test/deadline_cutoff_settings_test.dart | 373 ++++++++++++++++++ test/deadline_cutoff_test.dart | 339 ++++++++++++++++ 28 files changed, 2311 insertions(+), 35 deletions(-) create mode 100644 docs/deadline-cutoff.md create mode 100644 lib/agenda/deadline_cutoff.dart create mode 100644 lib/agenda/ui/adjusted_deadline_painter.dart create mode 100644 lib/app/ui/deadline_cutoff_settings.dart create mode 100644 test/agenda_adjusted_timeline_test.dart create mode 100644 test/deadline_cutoff_integration_test.dart create mode 100644 test/deadline_cutoff_settings_test.dart create mode 100644 test/deadline_cutoff_test.dart diff --git a/docs/deadline-cutoff.md b/docs/deadline-cutoff.md new file mode 100644 index 0000000..986ef93 --- /dev/null +++ b/docs/deadline-cutoff.md @@ -0,0 +1,50 @@ +# DDL 每日伪截止时间段 + +## 时间规则 + +设置默认关闭,预置每日 22:00–次日 08:00。开关、开始和结束分钟作为一份 +JSON 设置保存;关闭仍保留所选时间。 + +区间两端包含:开始早于结束时匹配同一天;开始晚于结束时匹配跨日区间, +凌晨截止归到前一天的开始时间。开始与结束相同时仅匹配该时间点,不代表 +全天。设置精度是分钟,边界时刻为该分钟的 00 秒:结束时刻包含,但其后 +的秒和微秒不包含。 + +只对有真实截止时间的 DDL 生效;普通日程、考试、仅开始时间的测验不变。 +全天 DDL 的真实截止仍按原规则视为下一天 00:00,因此也可命中跨日窗口。 + +## 数据与显示 + +`AgendaEntry.startsAt`、`allDay` 及序列化内容保持原始数据。 +`realDueAt` 是原始截止;`dueAt` 是派生有效截止;`scheduledAt` 用于实际 +调整后的日期分组和排序。生效配置只作为运行时投影,不写回日程存储。 +关闭或更改设置总是从原始时间重算,不能累计伪提前,也不改变平台链接、 +提交状态或同步匹配。 + +命中窗口与实际提前分开表示:起点或单时间点命中时,即使没有提前,仍需 +处理两条标线的绘制顺序。详情中实际发生提前时同时说明有效和真实截止, +编辑器继续编辑原始时间。 + +## 泳道图 + +命中窗口的 DDL: + +- 预留区间为 `[有效截止 − 4 天, 真实截止]`。 +- 左侧主体沿用最小缩放 3.75 天的屏幕定宽规则,不随缩放增长;右边界 + 位于有效截止,受影响条目使用固定 4 天而非自定义拖尾长度。 +- 两条标线之间按真实时间间距绘制矩形,宽度跟随时间轴缩放。 +- 填充为屏幕定宽的 45° 宽斜纹,同一配色、交替透明度;完成后再衰减。 +- 真实标线未完成时使用原标线的 0.5 透明度,完成后再衰减为 0.25。 +- 真实标线在最上层绘制,包括零间距或小于标线宽度的情况;零间距没有 + 斜纹区域。有效标线及左侧主体保留正常完成/逾期规则。 +- 延伸段按视口裁剪,不能创建按时间跨度无限扩大的画布;全局泳道高度 + 规则保持不变。 + +## 验证 + +回归测试覆盖时间规则、精确边界、跨日/跨年、单时间点、原始存储不变、 +实时设置投影、关闭恢复、设置持久化/竞态/异常、中文英文窄屏和时间选择器, +以及排版区间、定宽主体、缩放延伸、裁剪、双标线重叠和完成透明度。 + +本地没有 Flutter SDK 时仅进行静态核对,不将补丁检查或推送成功表述为 +测试通过;运行结果以 GitHub Actions 的分析和测试为准。 diff --git a/lib/agenda/calendar.dart b/lib/agenda/calendar.dart index eea2b1a..3b0b0b9 100644 --- a/lib/agenda/calendar.dart +++ b/lib/agenda/calendar.dart @@ -25,7 +25,7 @@ class CalendarItem { course: null, entry: value, location: value.location, - startsAt: value.startsAt, + startsAt: value.scheduledAt, endsAt: value.endsAt, ); diff --git a/lib/agenda/controller.dart b/lib/agenda/controller.dart index bbb3c31..d6c77fb 100644 --- a/lib/agenda/controller.dart +++ b/lib/agenda/controller.dart @@ -21,21 +21,31 @@ class AgendaController extends ChangeNotifier { bool _ready = false; bool _busy = false; bool _disposed = false; + DeadlineCutoffWindow _deadlineCutoffWindow = const DeadlineCutoffWindow(); + + void setDeadlineCutoff(DeadlineCutoffWindow window) { + if (_deadlineCutoffWindow == window) return; + _deadlineCutoffWindow = window; + _notify(); + } + + AgendaEntry _present(AgendaEntry entry) => + entry.withDeadlineCutoff(_deadlineCutoffWindow); bool get ready => _ready; bool get busy => _busy; Iterable get entries => - _entries.where((entry) => !entry.deleted); + _entries.where((entry) => !entry.deleted).map(_present); Iterable get deletedDeadlines => - _entries.where((entry) => entry.deleted && entry.isDeadline); + _entries.where((entry) => entry.deleted && entry.isDeadline).map(_present); List pendingOn(DateTime date, {required DateTime now}) => entries .where((entry) => !entry.isFinished(now) && entry.occursOn(date)) .toList() - ..sort((a, b) => a.startsAt.compareTo(b.startsAt)); + ..sort((a, b) => a.scheduledAt.compareTo(b.scheduledAt)); Future load() => _operations.run(() async { if (_disposed) return; diff --git a/lib/agenda/deadline_cutoff.dart b/lib/agenda/deadline_cutoff.dart new file mode 100644 index 0000000..c361508 --- /dev/null +++ b/lib/agenda/deadline_cutoff.dart @@ -0,0 +1,77 @@ +import '../core/json.dart'; +import '../core/time.dart'; + +/// A daily, inclusive campus wall-clock window. The real deadline is never +/// stored here or changed: [apply] only derives an earlier presentation time. +class DeadlineCutoffWindow { + const DeadlineCutoffWindow({ + this.enabled = false, + this.startMinute = 22 * 60, + this.endMinute = 8 * 60, + }) : assert(startMinute >= 0 && startMinute < 24 * 60), + assert(endMinute >= 0 && endMinute < 24 * 60); + + final bool enabled; + final int startMinute; + final int endMinute; + + bool get crossesMidnight => endMinute < startMinute; + bool get isSinglePoint => endMinute == startMinute; + + bool contains(DateTime actual) { + if (!enabled) return false; + final day = calendarDate(actual); + final start = day.add(Duration(minutes: startMinute)); + final end = day.add(Duration(minutes: endMinute)); + return crossesMidnight + ? !actual.isBefore(start) || !actual.isAfter(end) + : !actual.isBefore(start) && !actual.isAfter(end); + } + + DateTime apply(DateTime actual) { + if (!contains(actual)) return actual; + final start = calendarDate(actual).add(Duration(minutes: startMinute)); + return crossesMidnight && actual.isBefore(start) + ? start.subtract(const Duration(days: 1)) + : start; + } + + DeadlineCutoffWindow copyWith({ + bool? enabled, + int? startMinute, + int? endMinute, + }) => DeadlineCutoffWindow( + enabled: enabled ?? this.enabled, + startMinute: startMinute ?? this.startMinute, + endMinute: endMinute ?? this.endMinute, + ); + + Map toJson() => { + 'enabled': enabled, + 'startMinute': startMinute, + 'endMinute': endMinute, + }; + + factory DeadlineCutoffWindow.fromJson(Map json) { + final start = jsonIntField(json, 'startMinute'); + final end = jsonIntField(json, 'endMinute'); + if (start < 0 || start >= 24 * 60 || end < 0 || end >= 24 * 60) { + throw const FormatException('DDL 时间段必须在 00:00–23:59 内'); + } + return DeadlineCutoffWindow( + enabled: jsonBoolField(json, 'enabled'), + startMinute: start, + endMinute: end, + ); + } + + @override + bool operator ==(Object other) => + other is DeadlineCutoffWindow && + enabled == other.enabled && + startMinute == other.startMinute && + endMinute == other.endMinute; + + @override + int get hashCode => Object.hash(enabled, startMinute, endMinute); +} diff --git a/lib/agenda/model.dart b/lib/agenda/model.dart index ec407dd..001f4e8 100644 --- a/lib/agenda/model.dart +++ b/lib/agenda/model.dart @@ -1,8 +1,10 @@ import '../core/json.dart'; import '../core/time.dart'; +import 'deadline_cutoff.dart'; import 'deadline_source.dart'; import 'details.dart'; +export 'deadline_cutoff.dart'; export 'details.dart'; enum AgendaKind { event, deadline, exam } @@ -28,6 +30,7 @@ class AgendaEntry { this.details, this.tailDays = defaultTailDays, this.startOnly = false, + this.deadlineCutoffWindow = const DeadlineCutoffWindow(), }) { if (id.isEmpty || title.trim().isEmpty) { throw const FormatException('日程标题或编号缺失'); @@ -98,11 +101,37 @@ class AgendaEntry { /// as due. final bool startOnly; + /// Presentation-only state. It is deliberately absent from [toJson], so + /// changing the window or syncing can never overwrite the original deadline. + final DeadlineCutoffWindow deadlineCutoffWindow; + bool get isDeadline => kind == AgendaKind.deadline; - DateTime get dueAt => + DateTime get realDueAt => allDay ? calendarDate(startsAt).add(const Duration(days: 1)) : startsAt; + DateTime get dueAt => isDeadline && !startOnly + ? deadlineCutoffWindow.apply(realDueAt) + : realDueAt; + + /// Includes a deadline exactly at the window start: its two markers still + /// overlap and the real marker must paint above the effective one. + bool get hasDeadlineCutoff => + isDeadline && !startOnly && deadlineCutoffWindow.contains(realDueAt); + + bool get hasAdjustedDeadline => + hasDeadlineCutoff && dueAt.isBefore(realDueAt); + + /// Day grouping and labels follow the derived deadline only when adjusted. + /// Keep unadjusted all-day entries on their original calendar date. + DateTime get scheduledAt => hasAdjustedDeadline ? dueAt : startsAt; + bool get effectiveAllDay => allDay && !hasAdjustedDeadline; + + AgendaEntry withDeadlineCutoff(DeadlineCutoffWindow window) => + window == deadlineCutoffWindow + ? this + : copyWith(deadlineCutoffWindow: window); + bool overdue(DateTime now) => isDeadline && !startOnly && !done && dueAt.isBefore(now); @@ -110,12 +139,16 @@ class AgendaEntry { bool occursOn(DateTime date) { final day = calendarDate(date); - if (isDeadline) return calendarDate(startsAt) == day; + if (isDeadline) return calendarDate(scheduledAt) == day; return startsAt.isBefore(day.add(const Duration(days: 1))) && endsAt!.isAfter(day); } - AgendaEntry copyWith({bool? done, bool? deleted}) => AgendaEntry( + AgendaEntry copyWith({ + bool? done, + bool? deleted, + DeadlineCutoffWindow? deadlineCutoffWindow, + }) => AgendaEntry( id: id, title: title, kind: kind, @@ -134,6 +167,7 @@ class AgendaEntry { details: details, tailDays: tailDays, startOnly: startOnly, + deadlineCutoffWindow: deadlineCutoffWindow ?? this.deadlineCutoffWindow, ); Map toJson() => { diff --git a/lib/agenda/timeline_layout.dart b/lib/agenda/timeline_layout.dart index 956c79a..77a7881 100644 --- a/lib/agenda/timeline_layout.dart +++ b/lib/agenda/timeline_layout.dart @@ -29,12 +29,18 @@ List layoutAgendaTimeline( // that opening time, so both reserve `tailDays` of lane space. DateTime reservationStart(AgendaEntry entry) => entry.isDeadline && !entry.startOnly - ? entry.dueAt.subtract(Duration(days: entry.tailDays)) + ? entry.dueAt.subtract( + Duration( + days: entry.hasDeadlineCutoff + ? AgendaEntry.defaultTailDays + : entry.tailDays, + ), + ) : entry.startsAt; DateTime reservationEnd(AgendaEntry entry) => entry.isDeadline ? entry.startOnly ? entry.startsAt.add(Duration(days: entry.tailDays)) - : entry.dueAt + : entry.realDueAt : entry.endsAt!; final intervals = [ diff --git a/lib/agenda/ui/adjusted_deadline_painter.dart b/lib/agenda/ui/adjusted_deadline_painter.dart new file mode 100644 index 0000000..40860ca --- /dev/null +++ b/lib/agenda/ui/adjusted_deadline_painter.dart @@ -0,0 +1,162 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// Coordinates relative to the viewport-clipped adjusted-deadline surface. +/// The original body stays fixed-width even when only the extension is visible. +class AdjustedDeadlineGeometry { + const AdjustedDeadlineGeometry({ + required this.bodyLeft, + required this.effectiveX, + required this.realX, + }); + + final double bodyLeft; + final double effectiveX; + final double realX; +} + +/// Screen-space stripes, not time-space stripes: zoom stretches the interval, +/// but never the width or 45-degree angle of each band. +class AdjustedDeadlineExtensionPainter extends CustomPainter { + const AdjustedDeadlineExtensionPainter({ + required this.effectiveX, + required this.realX, + required this.color, + this.stripeWidth = 16, + }); + + final double effectiveX; + final double realX; + final Color color; + final double stripeWidth; + + @override + void paint(Canvas canvas, Size size) { + final left = math.max(0.0, effectiveX); + final right = math.min(size.width, realX); + if (right <= left || size.height <= 0) return; + canvas.save(); + canvas.clipRect(Rect.fromLTRB(left, 0, right, size.height)); + // Only visit stripes intersecting this small, clipped canvas. The original + // interval can span years at maximum zoom without allocating a huge layer. + final first = ((left - effectiveX) / stripeWidth).floor(); + final last = ((right + size.height - effectiveX) / stripeWidth).ceil(); + for (var index = first; index <= last; index++) { + final x = effectiveX + index * stripeWidth; + final path = Path() + ..moveTo(x, 0) + ..lineTo(x + stripeWidth, 0) + ..lineTo(x + stripeWidth - size.height, size.height) + ..lineTo(x - size.height, size.height) + ..close(); + canvas.drawPath( + path, + Paint() + ..color = color.withValues( + alpha: color.a * (index.isEven ? 0.32 : 0.16), + ), + ); + } + canvas.restore(); + } + + @override + bool shouldRepaint(AdjustedDeadlineExtensionPainter oldDelegate) => + oldDelegate.effectiveX != effectiveX || + oldDelegate.realX != realX || + oldDelegate.color != color || + oldDelegate.stripeWidth != stripeWidth; +} + +/// Always used as a foreground painter: even a subpixel interval must draw the +/// real deadline OVER the effective boundary rather than dropping either line. +class AdjustedDeadlineMarkerPainter extends CustomPainter { + const AdjustedDeadlineMarkerPainter({ + required this.realX, + required this.color, + this.lineWidth = 3, + }); + + final double realX; + final Color color; + final double lineWidth; + + @override + void paint(Canvas canvas, Size size) { + canvas.save(); + canvas.clipRect(Offset.zero & size); + canvas.drawRect( + Rect.fromLTRB(realX - lineWidth, 0, realX, size.height), + Paint()..color = color, + ); + canvas.restore(); + } + + @override + bool shouldRepaint(AdjustedDeadlineMarkerPainter oldDelegate) => + oldDelegate.realX != realX || + oldDelegate.color != color || + oldDelegate.lineWidth != lineWidth; +} + +/// A bounded hit/paint surface. [body] may extend a few baseline days outside +/// it, but the extension never creates a time-span-sized widget or canvas. +class AdjustedDeadlineSurface extends StatelessWidget { + const AdjustedDeadlineSurface({ + super.key, + required this.geometry, + required this.bodyWidth, + required this.fill, + required this.realMarkerColor, + required this.body, + required this.onTap, + required this.semanticsLabel, + }); + + final AdjustedDeadlineGeometry geometry; + final double bodyWidth; + final Color fill; + final Color realMarkerColor; + final Widget body; + final VoidCallback onTap; + final String semanticsLabel; + + @override + Widget build(BuildContext context) => Semantics( + button: true, + label: semanticsLabel, + onTap: onTap, + child: ExcludeSemantics( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: ClipRect( + child: CustomPaint( + painter: AdjustedDeadlineExtensionPainter( + effectiveX: geometry.effectiveX, + realX: geometry.realX, + color: fill, + ), + foregroundPainter: AdjustedDeadlineMarkerPainter( + realX: geometry.realX, + color: realMarkerColor, + ), + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned( + left: geometry.bodyLeft, + top: 0, + bottom: 0, + width: bodyWidth, + child: body, + ), + ], + ), + ), + ), + ), + ), + ); +} diff --git a/lib/agenda/ui/agenda_detail.dart b/lib/agenda/ui/agenda_detail.dart index 26aab73..acb8a14 100644 --- a/lib/agenda/ui/agenda_detail.dart +++ b/lib/agenda/ui/agenda_detail.dart @@ -251,24 +251,31 @@ class _EntryTime extends StatelessWidget { final theme = Theme.of(context); final scheme = theme.colorScheme; final color = entry.overdue(now) ? scheme.error : scheme.onSurface; + final displayedAt = entry.scheduledAt; + final allDay = entry.effectiveAllDay; // An open-ended deadline has no due date: it reads like a timed entry and // is labelled as opening rather than as due. final String time; if (!entry.isDeadline) { time = localizedAgendaTime(l10n, entry); } else if (entry.startOnly) { - time = entry.allDay ? l10n.allDay : clockText(entry.startsAt); + time = allDay ? l10n.allDay : clockText(displayedAt); } else { - time = entry.allDay + time = allDay ? l10n.agendaDueBeforeEndOfDay - : clockText(entry.startsAt); + : clockText(displayedAt); } - final lastDay = entry.allDay && !entry.isDeadline + final timeLabel = entry.hasAdjustedDeadline + ? l10n.agendaAdjustedDeadline + : entry.startOnly + ? l10n.start + : l10n.due; + final lastDay = allDay && !entry.isDeadline ? entry.endsAt!.subtract(const Duration(days: 1)) - : entry.startsAt; - final date = calendarDate(lastDay) == calendarDate(entry.startsAt) - ? localizedAgendaDate(l10n, entry.startsAt) - : '${localizedAgendaDate(l10n, entry.startsAt)} – ' + : displayedAt; + final date = calendarDate(lastDay) == calendarDate(displayedAt) + ? localizedAgendaDate(l10n, displayedAt) + : '${localizedAgendaDate(l10n, displayedAt)} – ' '${localizedAgendaDate(l10n, lastDay)}'; return DecoratedBox( @@ -296,21 +303,32 @@ class _EntryTime extends StatelessWidget { TextSpan( children: [ TextSpan(text: time), - if (entry.isDeadline && !entry.allDay) + if (entry.isDeadline && !allDay) TextSpan( - text: ' ${entry.startOnly ? l10n.start : l10n.due}', + text: ' $timeLabel', style: theme.textTheme.bodyMedium?.copyWith(color: color), ), ], ), style: theme.textTheme.headlineSmall?.copyWith( - fontSize: entry.isDeadline && !entry.allDay ? 30 : 22, + fontSize: entry.isDeadline && !allDay ? 30 : 22, height: 1.25, fontWeight: FontWeight.w500, color: color, fontFeatures: const [FontFeature.tabularFigures()], ), ), + if (entry.hasAdjustedDeadline) ...[ + const SizedBox(height: 12), + Text( + l10n.agendaCutoffOriginal( + localizedAgendaDateTime(l10n, entry.realDueAt), + ), + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], ], ), ), diff --git a/lib/agenda/ui/agenda_list_tile.dart b/lib/agenda/ui/agenda_list_tile.dart index 98e9808..4c1d58b 100644 --- a/lib/agenda/ui/agenda_list_tile.dart +++ b/lib/agenda/ui/agenda_list_tile.dart @@ -49,13 +49,17 @@ class AgendaListTile extends StatelessWidget { else if (entry.source != null) entry.source!.localizedLabel(l10n), if (entry.location.isNotEmpty) entry.location, + if (entry.hasAdjustedDeadline) + l10n.agendaCutoffOriginal( + localizedAgendaDateTime(l10n, entry.realDueAt), + ), ].join(' '); final description = localizedAgendaNotes(l10n, entry).trim(); final hasDetails = description.isNotEmpty || subtitle.isNotEmpty; final dueToday = entry.isDeadline && !entry.done && - calendarDate(entry.startsAt) == calendarDate(now); + calendarDate(entry.scheduledAt) == calendarDate(now); final timeColor = entry.overdue(now) ? scheme.error : dueToday diff --git a/lib/agenda/ui/agenda_timeline.dart b/lib/agenda/ui/agenda_timeline.dart index e8ecadf..e7110c3 100644 --- a/lib/agenda/ui/agenda_timeline.dart +++ b/lib/agenda/ui/agenda_timeline.dart @@ -11,6 +11,7 @@ import '../../l10n/formats.dart'; import '../../l10n/l10n.dart'; import '../model.dart'; import '../timeline_layout.dart'; +import 'adjusted_deadline_painter.dart'; import 'agenda_detail.dart'; import 'timeline_entry.dart'; @@ -61,17 +62,21 @@ class _AgendaTimelineState extends State int _gesturePointers = 0; DateTime? _gestureAnchor; List? _cachedEntries; + DeadlineCutoffWindow? _cachedCutoff; Duration? _cachedMinimumEventSpan; List _cachedPlacements = const []; final Map _nextStartById = {}; List _layout(Duration minimumEventSpan) { + final cutoff = widget.controller.settings.deadlineCutoff.value; if (!identical(_cachedEntries, widget.entries) || + _cachedCutoff != cutoff || _cachedMinimumEventSpan != minimumEventSpan) { _cachedEntries = widget.entries; + _cachedCutoff = cutoff; _cachedMinimumEventSpan = minimumEventSpan; _cachedPlacements = layoutAgendaTimeline( - widget.entries, + widget.entries.map((entry) => entry.withDeadlineCutoff(cutoff)), minimumEventSpan: minimumEventSpan, ); _nextStartById.clear(); @@ -90,6 +95,7 @@ class _AgendaTimelineState extends State super.initState(); _center = calendarDate(widget.now).add(const Duration(hours: 12)); _lanes.addListener(_laneChanged); + widget.controller.settings.deadlineCutoff.addListener(_laneChanged); _horizontalFling = AnimationController.unbounded(vsync: this) ..addListener(_tickHorizontal); _verticalFling = AnimationController.unbounded(vsync: this) @@ -154,6 +160,11 @@ class _AgendaTimelineState extends State @override void didUpdateWidget(covariant AgendaTimeline oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.settings.deadlineCutoff.removeListener(_laneChanged); + widget.controller.settings.deadlineCutoff.addListener(_laneChanged); + _cachedEntries = null; + } if (oldWidget.visible != widget.visible) { _stopMotion(); _gestureAnchor = null; @@ -190,6 +201,7 @@ class _AgendaTimelineState extends State _horizontalFling.dispose(); _verticalFling.dispose(); _lanes.removeListener(_laneChanged); + widget.controller.settings.deadlineCutoff.removeListener(_laneChanged); _lanes.dispose(); super.dispose(); } @@ -278,7 +290,11 @@ class _AgendaTimelineState extends State // Keep the tail a quarter baseline day inside its reservation. // Width is set by the MINIMUM zoom, never by the current zoom. double deadlineWidth(AgendaEntry entry) => - cellWidth * (entry.tailDays - 0.25); + cellWidth * + ((entry.hasDeadlineCutoff + ? AgendaEntry.defaultTailDays + : entry.tailDays) - + 0.25); Duration deadlineDuration(AgendaEntry entry) => Duration( microseconds: (deadlineWidth(entry) / pixelsPerHour * _microsecondsPerHour) @@ -290,7 +306,7 @@ class _AgendaTimelineState extends State // than the wider reservation. This does not change the scroll range. DateTime paintedStart(AgendaTimelinePlacement placement) => placement.entry.isDeadline && !placement.entry.startOnly - ? placement.end.subtract(deadlineDuration(placement.entry)) + ? placement.entry.dueAt.subtract(deadlineDuration(placement.entry)) : placement.start; DateTime paintedEnd(AgendaTimelinePlacement placement) => placement.entry.isDeadline && placement.entry.startOnly @@ -655,6 +671,34 @@ class _AgendaTimelineState extends State time.difference(_center).inMicroseconds / _microsecondsPerHour * pixelsPerHour; + if (entry.hasDeadlineCutoff) { + final effective = x(entry.dueAt); + final real = x(entry.realDueAt); + final bodyLeft = effective - deadlineWidth; + // The extension may be enormous in time coordinates. Only allocate its + // intersection with this viewport, while retaining the body's true origin. + final left = math.max(0.0, bodyLeft); + final right = math.min(width, real); + return Positioned( + key: ValueKey(('agenda-timeline-entry', entry.id)), + left: left, + top: placement.lane * laneHeight + 12, + width: math.max(0.0, right - left), + height: laneHeight - 12, + child: AgendaTimelineEntry( + entry: entry, + now: widget.now, + barWidth: deadlineWidth, + deadlineWidth: deadlineWidth, + adjustedGeometry: AdjustedDeadlineGeometry( + bodyLeft: bodyLeft - left, + effectiveX: effective - left, + realX: real - left, + ), + onTap: () => showAgendaEntry(context, widget.controller, entry), + ), + ); + } // A start-only deadline keeps the usual painted width but grows rightwards // from its opening time, so its right edge is derived instead of its left. final actualRight = entry.isDeadline && entry.startOnly @@ -673,6 +717,7 @@ class _AgendaTimelineState extends State : math.max(0.0, x(nextStart) - left); final tapWidth = math.min(math.max(24.0, barWidth), availableWidth); return Positioned( + key: ValueKey(('agenda-timeline-entry', entry.id)), left: left, top: placement.lane * laneHeight + 12, width: tapWidth, diff --git a/lib/agenda/ui/calendar_list.dart b/lib/agenda/ui/calendar_list.dart index a64951d..d19ffa6 100644 --- a/lib/agenda/ui/calendar_list.dart +++ b/lib/agenda/ui/calendar_list.dart @@ -38,7 +38,7 @@ Future showAgendaDay( controller.agenda.entries .where((entry) => entry.done && entry.occursOn(date)) .toList() - ..sort((a, b) => a.startsAt.compareTo(b.startsAt)); + ..sort((a, b) => a.scheduledAt.compareTo(b.scheduledAt)); Widget row(AgendaEntry entry) => AgendaListTile( key: ValueKey(entry.id), controller: controller, diff --git a/lib/agenda/ui/calendar_screen.dart b/lib/agenda/ui/calendar_screen.dart index 0abecf0..47e0578 100644 --- a/lib/agenda/ui/calendar_screen.dart +++ b/lib/agenda/ui/calendar_screen.dart @@ -311,7 +311,7 @@ class _AgendaStatusList extends StatelessWidget { final visible = entries.where((entry) => entry.isFinished(now) == completed).toList() ..sort((a, b) { - final time = a.startsAt.compareTo(b.startsAt); + final time = a.scheduledAt.compareTo(b.scheduledAt); final order = time == 0 ? a.id.compareTo(b.id) : time; return completed ? -order : order; }); @@ -319,7 +319,7 @@ class _AgendaStatusList extends StatelessWidget { final overdueDays = >{}; for (final entry in visible) { final group = entry.overdue(now) ? overdueDays : days; - group.putIfAbsent(calendarDate(entry.startsAt), () => []).add(entry); + group.putIfAbsent(calendarDate(entry.scheduledAt), () => []).add(entry); } List dayRows(Map> groups) => [ diff --git a/lib/agenda/ui/deleted_deadlines.dart b/lib/agenda/ui/deleted_deadlines.dart index fcefe9c..5e99ae9 100644 --- a/lib/agenda/ui/deleted_deadlines.dart +++ b/lib/agenda/ui/deleted_deadlines.dart @@ -141,7 +141,7 @@ class _DeletedDeadlinesScreenState extends State { final controller = widget.controller; final entries = controller.deletedDeadlines.toList() ..sort((a, b) { - final time = b.startsAt.compareTo(a.startsAt); + final time = b.scheduledAt.compareTo(a.scheduledAt); return time == 0 ? a.id.compareTo(b.id) : time; }); final query = _search.text.trim().toLowerCase(); @@ -155,7 +155,7 @@ class _DeletedDeadlinesScreenState extends State { !_restoring && !_emptying && controller.ready && !controller.busy; final groups = >{}; for (final entry in visible) { - groups.putIfAbsent(calendarDate(entry.startsAt), () => []).add(entry); + groups.putIfAbsent(calendarDate(entry.scheduledAt), () => []).add(entry); } final rows = <({DateTime? day, AgendaEntry? entry})>[ for (final group in groups.entries) ...[ diff --git a/lib/agenda/ui/timeline_entry.dart b/lib/agenda/ui/timeline_entry.dart index 6d5dbd1..f11f009 100644 --- a/lib/agenda/ui/timeline_entry.dart +++ b/lib/agenda/ui/timeline_entry.dart @@ -6,6 +6,7 @@ import '../../core/time.dart'; import '../../l10n/formats.dart'; import '../../l10n/l10n.dart'; import '../model.dart'; +import 'adjusted_deadline_painter.dart'; import 'timeline_colors.dart'; /// Renders entry content independently of timeline coordinates and lane packing. @@ -17,6 +18,7 @@ class AgendaTimelineEntry extends StatelessWidget { required this.barWidth, required this.deadlineWidth, required this.onTap, + this.adjustedGeometry, }); final AgendaEntry entry; @@ -24,9 +26,29 @@ class AgendaTimelineEntry extends StatelessWidget { final double barWidth; final double deadlineWidth; final VoidCallback onTap; + final AdjustedDeadlineGeometry? adjustedGeometry; @override Widget build(BuildContext context) { + final body = _buildBody(context); + final geometry = adjustedGeometry; + if (geometry == null) return body; + final colors = agendaTimelineColors(context, entry, now); + return AdjustedDeadlineSurface( + key: ValueKey(('agenda-timeline-adjusted', entry.id)), + geometry: geometry, + bodyWidth: deadlineWidth, + fill: colors.fill, + // colors.accent already includes the ordinary completed alpha. Applying + // another half gives 0.5 when pending and 0.25 when completed. + realMarkerColor: colors.accent.withValues(alpha: colors.accent.a * 0.5), + body: body, + onTap: onTap, + semanticsLabel: _preview(context.l10n, entry, now), + ); + } + + Widget _buildBody(BuildContext context) { final l10n = context.l10n; final scheme = Theme.of(context).colorScheme; final colors = agendaTimelineColors(context, entry, now); @@ -35,10 +57,10 @@ class AgendaTimelineEntry extends StatelessWidget { 1.0, MediaQuery.textScalerOf(context).scale(14) / 14, ); - final date = entry.startsAt; + final date = entry.scheduledAt; final caption = _caption(l10n, entry); final overdueCaption = l10n.agendaReminderOverdue( - entry.allDay ? l10n.allDay : clockText(entry.dueAt), + entry.effectiveAllDay ? l10n.allDay : clockText(entry.dueAt), ); final warningBackground = scheme.errorContainer; final warningForeground = scheme.onErrorContainer; @@ -312,7 +334,12 @@ String _caption(AppLocalizations l10n, AgendaEntry entry) { if (entry.startOnly) { return l10n.agendaTimelineStarts(clockText(entry.startsAt)); } - return entry.allDay ? l10n.allDayUntil : l10n.dueAt(clockText(entry.dueAt)); + if (entry.hasAdjustedDeadline) { + return '${clockText(entry.dueAt)} ${l10n.agendaAdjustedDeadline}'; + } + return entry.effectiveAllDay + ? l10n.allDayUntil + : l10n.dueAt(clockText(entry.dueAt)); } final end = entry.endsAt!; if (entry.allDay) { @@ -336,6 +363,12 @@ String _fullTime(AppLocalizations l10n, DateTime date) => /// The date line of a deadline preview. Open-ended deadlines never claim a /// time is due; they state when the task opened instead. String _previewDeadlineLine(AppLocalizations l10n, AgendaEntry entry) { + if (entry.hasDeadlineCutoff) { + return [ + l10n.agendaCutoffAdjusted(_fullTime(l10n, entry.dueAt)), + l10n.agendaCutoffOriginal(_fullTime(l10n, entry.realDueAt)), + ].join('\n'); + } if (entry.startOnly) { return l10n.agendaTimelineStarts( entry.allDay diff --git a/lib/app/controller.dart b/lib/app/controller.dart index 119add9..0a80fe2 100644 --- a/lib/app/controller.dart +++ b/lib/app/controller.dart @@ -53,8 +53,13 @@ class AppController extends ChangeNotifier { timetable: timetable, agenda: agenda, ); + settings.deadlineCutoff.addListener(_updateDeadlineCutoff); + _updateDeadlineCutoff(); } + void _updateDeadlineCutoff() => + agenda.setDeadlineCutoff(settings.deadlineCutoff.value); + final AppSettingsController settings; final TimetableController timetable; late final SchoolController school; @@ -80,6 +85,7 @@ class AppController extends ChangeNotifier { @override void dispose() { + settings.deadlineCutoff.removeListener(_updateDeadlineCutoff); settings.dispose(); sync.dispose(); classrooms.dispose(); diff --git a/lib/app/settings_controller.dart b/lib/app/settings_controller.dart index 724244c..6817a20 100644 --- a/lib/app/settings_controller.dart +++ b/lib/app/settings_controller.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../agenda/deadline_cutoff.dart'; import '../agenda/view_mode.dart'; import '../core/async/serial_queue.dart'; import '../core/diagnostics.dart'; @@ -45,6 +46,7 @@ class AppSettingsController extends ChangeNotifier { final SystemPaletteSource _systemPaletteSource; final agendaViewMode = ValueNotifier(AgendaViewMode.timeline); final timetableHeightMode = ValueNotifier(TimetableHeightMode.normal); + final deadlineCutoff = ValueNotifier(const DeadlineCutoffWindow()); Future? _paletteSettingsLoad; SystemPalette? _systemPalette; @@ -53,6 +55,7 @@ class AppSettingsController extends ChangeNotifier { bool _themeChosen = false; bool _agendaViewChosen = false; bool _timetableHeightChosen = false; + bool _deadlineCutoffChosen = false; bool _developerMode = false; bool _widgetUseSystemPalette = false; bool _globalUseSystemPalette = false; @@ -145,6 +148,19 @@ class AppSettingsController extends ChangeNotifier { } on Exception catch (failure, stack) { AppDiagnostics.instance.error('读取课表高度设置', failure, stack); } + try { + final savedCutoff = await _preferences.getString('deadlineCutoff'); + if (!_deadlineCutoffChosen && !_disposed && savedCutoff != null) { + final decoded = jsonDecode(savedCutoff); + if (decoded is! Map) { + throw const FormatException('DDL 伪提前设置格式不正确'); + } + deadlineCutoff.value = DeadlineCutoffWindow.fromJson(decoded); + _notify(); + } + } catch (failure, stack) { + AppDiagnostics.instance.report('读取 DDL 伪提前设置', failure, stack); + } } Future setTheme(ThemeMode value) { @@ -344,6 +360,18 @@ class AppSettingsController extends ChangeNotifier { return _writePreference('timetableHeightMode', value.name, '保存课表高度设置'); } + Future setDeadlineCutoff(DeadlineCutoffWindow value) { + if (_disposed) return Future.value(); + _deadlineCutoffChosen = true; + deadlineCutoff.value = value; + _notify(); + return _writePreference( + 'deadlineCutoff', + jsonEncode(value.toJson()), + '保存 DDL 伪提前设置', + ); + } + Future _writePreference(String key, String value, String operation) { final previous = _preferenceWrites[key] ?? Future.value(); final write = previous.then((_) async { @@ -368,6 +396,7 @@ class AppSettingsController extends ChangeNotifier { _systemPaletteSource.dispose(); agendaViewMode.dispose(); timetableHeightMode.dispose(); + deadlineCutoff.dispose(); super.dispose(); } } diff --git a/lib/app/ui/deadline_cutoff_settings.dart b/lib/app/ui/deadline_cutoff_settings.dart new file mode 100644 index 0000000..773d018 --- /dev/null +++ b/lib/app/ui/deadline_cutoff_settings.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; + +import '../../agenda/deadline_cutoff.dart'; +import '../../l10n/l10n.dart'; +import '../../ui/app_page.dart'; +import '../settings_controller.dart'; + +String _clock(BuildContext context, int minute) => + MaterialLocalizations.of(context).formatTimeOfDay( + TimeOfDay(hour: minute ~/ 60, minute: minute % 60), + alwaysUse24HourFormat: true, + ); + +String _windowSummary(BuildContext context, DeadlineCutoffWindow window) { + final l10n = context.l10n; + final start = _clock(context, window.startMinute); + final end = _clock(context, window.endMinute); + if (window.startMinute == window.endMinute) { + return l10n.deadlineCutoffInstant(start); + } + return window.startMinute > window.endMinute + ? l10n.deadlineCutoffOvernight(start, end) + : l10n.deadlineCutoffSameDay(start, end); +} + +class DeadlineCutoffSettingsTile extends StatelessWidget { + const DeadlineCutoffSettingsTile({super.key, required this.settings}); + + final AppSettingsController settings; + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: settings.deadlineCutoff, + builder: (context, window, _) => ListTile( + key: const ValueKey('deadline-cutoff-settings'), + contentPadding: const EdgeInsets.symmetric(vertical: 2), + leading: const Icon(Icons.more_time_rounded), + title: Text(context.l10n.settingsDeadlineCutoff), + subtitle: Text( + window.enabled + ? _windowSummary(context, window) + : context.l10n.deadlineCutoffDisabled, + ), + trailing: const Icon(Icons.chevron_right_rounded, size: 20), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DeadlineCutoffScreen(settings: settings), + ), + ), + ), + ); +} + +class DeadlineCutoffScreen extends StatelessWidget { + const DeadlineCutoffScreen({super.key, required this.settings}); + + final AppSettingsController settings; + + Future _selectTime(BuildContext context, {required bool start}) async { + final current = settings.deadlineCutoff.value; + final minute = start ? current.startMinute : current.endMinute; + final selected = await showTimePicker( + context: context, + initialTime: TimeOfDay(hour: minute ~/ 60, minute: minute % 60), + initialEntryMode: TimePickerEntryMode.input, + helpText: start + ? context.l10n.deadlineCutoffStart + : context.l10n.deadlineCutoffEnd, + ); + if (selected == null || !context.mounted) return; + final selectedMinute = selected.hour * 60 + selected.minute; + // A picker may stay open while another setting changes. Retain the newest + // enabled flag and opposite endpoint rather than writing the old snapshot. + final latest = settings.deadlineCutoff.value; + await settings.setDeadlineCutoff( + start + ? latest.copyWith(startMinute: selectedMinute) + : latest.copyWith(endMinute: selectedMinute), + ); + } + + @override + Widget build(BuildContext context) => AppRoutePage( + title: context.l10n.settingsDeadlineCutoff, + child: ValueListenableBuilder( + valueListenable: settings.deadlineCutoff, + builder: (context, window, _) { + final l10n = context.l10n; + final hintStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ); + return ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + children: [ + SwitchListTile( + key: const ValueKey('deadline-cutoff-enabled'), + contentPadding: EdgeInsets.zero, + title: Text(l10n.deadlineCutoffEnabled), + subtitle: Text(l10n.deadlineCutoffDescription), + value: window.enabled, + onChanged: (enabled) => settings.setDeadlineCutoff( + window.copyWith(enabled: enabled), + ), + ), + const SizedBox(height: 12), + Text(_windowSummary(context, window), style: hintStyle), + const SizedBox(height: 12), + ListTile( + key: const ValueKey('deadline-cutoff-start'), + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.schedule_rounded), + title: Text(l10n.deadlineCutoffStart), + subtitle: Text(_clock(context, window.startMinute)), + trailing: const Icon(Icons.edit_outlined), + onTap: () => _selectTime(context, start: true), + ), + ListTile( + key: const ValueKey('deadline-cutoff-end'), + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.schedule_rounded), + title: Text(l10n.deadlineCutoffEnd), + subtitle: Text(_clock(context, window.endMinute)), + trailing: const Icon(Icons.edit_outlined), + onTap: () => _selectTime(context, start: false), + ), + const SizedBox(height: 12), + Text(l10n.deadlineCutoffBoundaryHint, style: hintStyle), + const SizedBox(height: 8), + Text(l10n.deadlineCutoffPreserveHint, style: hintStyle), + ], + ); + }, + ), + ); +} diff --git a/lib/app/ui/settings_screen.dart b/lib/app/ui/settings_screen.dart index cf7f1bd..34ccec5 100644 --- a/lib/app/ui/settings_screen.dart +++ b/lib/app/ui/settings_screen.dart @@ -19,6 +19,7 @@ import '../controller.dart'; import '../settings_controller.dart'; import 'about_dialog.dart'; import 'appearance_screen.dart'; +import 'deadline_cutoff_settings.dart'; import 'logs_screen.dart'; class SettingsScreen extends StatelessWidget { @@ -211,6 +212,7 @@ class SettingsScreen extends StatelessWidget { ), ]), section(l10n.settingsLocalAgenda, [ + DeadlineCutoffSettingsTile(settings: controller.settings), ListTile( contentPadding: tilePadding, leading: Icon(Icons.delete_outline_rounded, color: scheme.error), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f35523e..129de58 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1724,6 +1724,62 @@ "timetableHeightNormal": "Normal height", "timetableHeightFit": "Fit to screen", "timetableHeightHint": "Fit to screen shows every period at once. Normal height keeps comfortable rows and vertical scrolling.", + "settingsDeadlineCutoff": "Deadline cutoff window", + "deadlineCutoffEnabled": "Enable daily cutoff window", + "deadlineCutoffDescription": "Move DDLs within this window to its start. Events, exams and open-ended quizzes are unaffected.", + "deadlineCutoffStart": "Start time", + "deadlineCutoffEnd": "End time", + "deadlineCutoffDisabled": "Not enabled", + "deadlineCutoffSameDay": "Daily, {start}–{end}", + "@deadlineCutoffSameDay": { + "placeholders": { + "start": { + "type": "String" + }, + "end": { + "type": "String" + } + } + }, + "deadlineCutoffOvernight": "Daily, {start} to {end} the next day", + "@deadlineCutoffOvernight": { + "placeholders": { + "start": { + "type": "String" + }, + "end": { + "type": "String" + } + } + }, + "deadlineCutoffInstant": "Only the instant {time} each day (not all day)", + "@deadlineCutoffInstant": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "deadlineCutoffBoundaryHint": "Both endpoints are included. An end before the start crosses midnight; equal times match only one instant.", + "deadlineCutoffPreserveHint": "Actual deadlines stay unchanged. Turning this off retains your selected start and end times.", + "agendaAdjustedDeadline": "Adjusted deadline", + "agendaRealDeadline": "Actual deadline", + "agendaCutoffOriginal": "Actual deadline: {time}", + "@agendaCutoffOriginal": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "agendaCutoffAdjusted": "Adjusted deadline: {time}", + "@agendaCutoffAdjusted": { + "placeholders": { + "time": { + "type": "String" + } + } + }, "releaseVersion": "{channel} {version}", "@releaseVersion": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 443f751..c78c8e5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4230,6 +4230,96 @@ abstract class AppLocalizations { /// **'Fit to screen shows every period at once. Normal height keeps comfortable rows and vertical scrolling.'** String get timetableHeightHint; + /// No description provided for @settingsDeadlineCutoff. + /// + /// In en, this message translates to: + /// **'Deadline cutoff window'** + String get settingsDeadlineCutoff; + + /// No description provided for @deadlineCutoffEnabled. + /// + /// In en, this message translates to: + /// **'Enable daily cutoff window'** + String get deadlineCutoffEnabled; + + /// No description provided for @deadlineCutoffDescription. + /// + /// In en, this message translates to: + /// **'Move DDLs within this window to its start. Events, exams and open-ended quizzes are unaffected.'** + String get deadlineCutoffDescription; + + /// No description provided for @deadlineCutoffStart. + /// + /// In en, this message translates to: + /// **'Start time'** + String get deadlineCutoffStart; + + /// No description provided for @deadlineCutoffEnd. + /// + /// In en, this message translates to: + /// **'End time'** + String get deadlineCutoffEnd; + + /// No description provided for @deadlineCutoffDisabled. + /// + /// In en, this message translates to: + /// **'Not enabled'** + String get deadlineCutoffDisabled; + + /// No description provided for @deadlineCutoffSameDay. + /// + /// In en, this message translates to: + /// **'Daily, {start}–{end}'** + String deadlineCutoffSameDay(String start, String end); + + /// No description provided for @deadlineCutoffOvernight. + /// + /// In en, this message translates to: + /// **'Daily, {start} to {end} the next day'** + String deadlineCutoffOvernight(String start, String end); + + /// No description provided for @deadlineCutoffInstant. + /// + /// In en, this message translates to: + /// **'Only the instant {time} each day (not all day)'** + String deadlineCutoffInstant(String time); + + /// No description provided for @deadlineCutoffBoundaryHint. + /// + /// In en, this message translates to: + /// **'Both endpoints are included. An end before the start crosses midnight; equal times match only one instant.'** + String get deadlineCutoffBoundaryHint; + + /// No description provided for @deadlineCutoffPreserveHint. + /// + /// In en, this message translates to: + /// **'Actual deadlines stay unchanged. Turning this off retains your selected start and end times.'** + String get deadlineCutoffPreserveHint; + + /// No description provided for @agendaAdjustedDeadline. + /// + /// In en, this message translates to: + /// **'Adjusted deadline'** + String get agendaAdjustedDeadline; + + /// No description provided for @agendaRealDeadline. + /// + /// In en, this message translates to: + /// **'Actual deadline'** + String get agendaRealDeadline; + + /// No description provided for @agendaCutoffOriginal. + /// + /// In en, this message translates to: + /// **'Actual deadline: {time}'** + String agendaCutoffOriginal(String time); + + /// No description provided for @agendaCutoffAdjusted. + /// + /// In en, this message translates to: + /// **'Adjusted deadline: {time}'** + String agendaCutoffAdjusted(String time); + /// No description provided for @releaseVersion. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 88032be..6640b24 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2457,6 +2457,61 @@ class AppLocalizationsEn extends AppLocalizations { String get timetableHeightHint => 'Fit to screen shows every period at once. Normal height keeps comfortable rows and vertical scrolling.'; + @override + String get settingsDeadlineCutoff => 'Deadline cutoff window'; + + @override + String get deadlineCutoffEnabled => 'Enable daily cutoff window'; + + @override + String get deadlineCutoffDescription => 'Move DDLs within this window to its start. Events, exams and open-ended quizzes are unaffected.'; + + @override + String get deadlineCutoffStart => 'Start time'; + + @override + String get deadlineCutoffEnd => 'End time'; + + @override + String get deadlineCutoffDisabled => 'Not enabled'; + + @override + String deadlineCutoffSameDay(String start, String end) { + return 'Daily, $start–$end'; + } + + @override + String deadlineCutoffOvernight(String start, String end) { + return 'Daily, $start to $end the next day'; + } + + @override + String deadlineCutoffInstant(String time) { + return 'Only the instant $time each day (not all day)'; + } + + @override + String get deadlineCutoffBoundaryHint => 'Both endpoints are included. An end before the start crosses midnight; equal times match only one instant.'; + + @override + String get deadlineCutoffPreserveHint => 'Actual deadlines stay unchanged. Turning this off retains your selected start and end times.'; + + @override + String get agendaAdjustedDeadline => 'Adjusted deadline'; + + @override + String get agendaRealDeadline => 'Actual deadline'; + + @override + String agendaCutoffOriginal(String time) { + return 'Actual deadline: $time'; + } + + @override + String agendaCutoffAdjusted(String time) { + return 'Adjusted deadline: $time'; + } + @override String releaseVersion(String channel, String version) { return '$channel $version'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 9a82dff..12d9c57 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2343,6 +2343,61 @@ class AppLocalizationsZh extends AppLocalizations { @override String get timetableHeightHint => '适应屏幕可一屏总览全部节次;正常高度保留舒适行高和纵向滚动。'; + @override + String get settingsDeadlineCutoff => 'DDL 伪提前'; + + @override + String get deadlineCutoffEnabled => '启用每日伪提前'; + + @override + String get deadlineCutoffDescription => '将时间段内的 DDL 伪提前至该段起点;不影响普通日程、考试或无截止日期测验。'; + + @override + String get deadlineCutoffStart => '开始时间'; + + @override + String get deadlineCutoffEnd => '结束时间'; + + @override + String get deadlineCutoffDisabled => '未启用'; + + @override + String deadlineCutoffSameDay(String start, String end) { + return '每天 $start–$end'; + } + + @override + String deadlineCutoffOvernight(String start, String end) { + return '每天 $start 至次日 $end'; + } + + @override + String deadlineCutoffInstant(String time) { + return '仅每天 $time 这一时刻(不是全天)'; + } + + @override + String get deadlineCutoffBoundaryHint => '开始和结束时刻均包含在区间内。结束早于开始时表示跨日;两者相同时仅匹配单个时刻。'; + + @override + String get deadlineCutoffPreserveHint => '真实截止时间保持不变。关闭后不再伪提前,但会保留已选择的开始和结束时间。'; + + @override + String get agendaAdjustedDeadline => '伪截止时间'; + + @override + String get agendaRealDeadline => '真实截止时间'; + + @override + String agendaCutoffOriginal(String time) { + return '真实截止:$time'; + } + + @override + String agendaCutoffAdjusted(String time) { + return '伪截止:$time'; + } + @override String releaseVersion(String channel, String version) { return '$channel $version'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 3349128..5e17abe 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -685,5 +685,20 @@ "timetableHeightNormal": "正常高度", "timetableHeightFit": "适应屏幕", "timetableHeightHint": "适应屏幕可一屏总览全部节次;正常高度保留舒适行高和纵向滚动。", + "settingsDeadlineCutoff": "DDL 伪提前", + "deadlineCutoffEnabled": "启用每日伪提前", + "deadlineCutoffDescription": "将时间段内的 DDL 伪提前至该段起点;不影响普通日程、考试或无截止日期测验。", + "deadlineCutoffStart": "开始时间", + "deadlineCutoffEnd": "结束时间", + "deadlineCutoffDisabled": "未启用", + "deadlineCutoffSameDay": "每天 {start}–{end}", + "deadlineCutoffOvernight": "每天 {start} 至次日 {end}", + "deadlineCutoffInstant": "仅每天 {time} 这一时刻(不是全天)", + "deadlineCutoffBoundaryHint": "开始和结束时刻均包含在区间内。结束早于开始时表示跨日;两者相同时仅匹配单个时刻。", + "deadlineCutoffPreserveHint": "真实截止时间保持不变。关闭后不再伪提前,但会保留已选择的开始和结束时间。", + "agendaAdjustedDeadline": "伪截止时间", + "agendaRealDeadline": "真实截止时间", + "agendaCutoffOriginal": "真实截止:{time}", + "agendaCutoffAdjusted": "伪截止:{time}", "releaseVersion": "{channel} {version}" } diff --git a/lib/l10n/formats.dart b/lib/l10n/formats.dart index 5755257..ce54ee2 100644 --- a/lib/l10n/formats.dart +++ b/lib/l10n/formats.dart @@ -157,14 +157,16 @@ extension LocalizedDeadlineScope on DeadlineClearScope { } String localizedAgendaTime(AppLocalizations l10n, AgendaEntry entry) { - if (entry.allDay) return l10n.allDay; + if (entry.effectiveAllDay) return l10n.allDay; if (entry.isDeadline) { // Without a due date there is nothing to be due at; the opening time is // all this entry knows. if (entry.startOnly) { return l10n.agendaTimelineStarts(clockText(entry.startsAt)); } - return l10n.dueAt(clockText(entry.startsAt)); + return entry.hasAdjustedDeadline + ? l10n.agendaCutoffAdjusted(clockText(entry.dueAt)) + : l10n.dueAt(clockText(entry.startsAt)); } final end = entry.endsAt!; final endTime = calendarDate(entry.startsAt) == calendarDate(end) @@ -259,7 +261,7 @@ String localizedCalendarItemTitle(AppLocalizations l10n, CalendarItem item) => : localizedAgendaTitle(l10n, item.entry!); String localizedAgendaClock(AppLocalizations l10n, AgendaEntry entry) => - entry.allDay ? l10n.allDay : clockText(entry.startsAt); + entry.effectiveAllDay ? l10n.allDay : clockText(entry.scheduledAt); String localizedAgendaTimeCaption( AppLocalizations l10n, diff --git a/test/agenda_adjusted_timeline_test.dart b/test/agenda_adjusted_timeline_test.dart new file mode 100644 index 0000000..1e9ad83 --- /dev/null +++ b/test/agenda_adjusted_timeline_test.dart @@ -0,0 +1,392 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:bitree/agenda/deadline_source.dart'; +import 'package:bitree/agenda/model.dart'; +import 'package:bitree/agenda/timeline_layout.dart'; +import 'package:bitree/agenda/ui/adjusted_deadline_painter.dart'; +import 'package:bitree/agenda/ui/agenda_timeline.dart'; +import 'package:bitree/agenda/ui/timeline_entry.dart'; +import 'package:bitree/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/test_app.dart'; + +final _date = DateTime.utc(2026, 11, 17); +const _window = DeadlineCutoffWindow( + enabled: true, + startMinute: 10 * 60, + endMinute: 18 * 60, +); + +AgendaEntry _deadline({ + String id = 'adjusted', + int tailDays = 4, + bool done = false, + bool startOnly = false, + DateTime? at, + DeadlineCutoffWindow window = _window, +}) => AgendaEntry( + id: id, + title: '测试截止时间', + kind: AgendaKind.deadline, + startsAt: at ?? _date.add(const Duration(hours: 14)), + tailDays: tailDays, + done: done, + source: DeadlineSource.eclass, + startOnly: startOnly, + deadlineCutoffWindow: window, +); + +Finder _body(AgendaEntry entry) => + find.byKey(ValueKey(('agenda-timeline-bar', entry.id))); + +Finder _surface(AgendaEntry entry) => + find.byKey(ValueKey(('agenda-timeline-adjusted', entry.id))); + +Future _showTimeline( + WidgetTester tester, + List entries, { + DeadlineCutoffWindow window = _window, +}) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final app = TestAppController(entries: entries); + await app.initialize(); + await app.settings.setDeadlineCutoff(window); + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: AgendaTimeline( + controller: app, + entries: entries, + now: _date.add(const Duration(hours: 8)), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return app; +} + +Future _pixels(WidgetTester tester, GlobalKey key) async => + (await tester.runAsync(() async { + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 1); + final data = await image.toByteData( + format: ui.ImageByteFormat.rawStraightRgba, + ); + image.dispose(); + return data!; + }))!; + +void main() { + test('Adjusted reservations use four days and include the real interval', () { + final entry = _deadline(tailDays: 14); + final placement = layoutAgendaTimeline( + [entry], + minimumEventSpan: const Duration(minutes: 1), + ).single; + expect(placement.start, entry.dueAt.subtract(const Duration(days: 4))); + expect(placement.end, entry.realDueAt); + expect(placement.layoutEnd, entry.realDueAt); + expect(entry.startsAt, _date.add(const Duration(hours: 14))); + expect(entry.tailDays, 14); + }); + + test('An event inside the extension must occupy another lane', () { + final ddl = _deadline(); + final event = AgendaEntry( + id: 'event', + title: '间隙中的日程', + kind: AgendaKind.event, + startsAt: _date.add(const Duration(hours: 11)), + endsAt: _date.add(const Duration(hours: 12)), + ); + final placements = layoutAgendaTimeline( + [ddl, event], + minimumEventSpan: const Duration(minutes: 1), + ); + expect(placements.map((p) => p.lane).toSet(), hasLength(2)); + }); + + test('Unadjusted and start-only reservations retain their custom tails', () { + for (final startOnly in [false, true]) { + final entry = _deadline( + tailDays: 14, + startOnly: startOnly, + window: const DeadlineCutoffWindow(), + ); + final placement = layoutAgendaTimeline( + [entry], + minimumEventSpan: const Duration(minutes: 1), + ).single; + expect( + placement.start, + startOnly + ? entry.startsAt + : entry.dueAt.subtract(const Duration(days: 14)), + ); + expect( + placement.end, + startOnly + ? entry.startsAt.add(const Duration(days: 14)) + : entry.realDueAt, + ); + } + }); + + testWidgets('The fixed body ends at effective due; the extension zooms', ( + tester, + ) async { + final entry = _deadline(tailDays: 14); + await _showTimeline(tester, [entry]); + final before = tester.getRect(_body(entry)); + final geometryBefore = + tester.widget(_surface(entry)).geometry; + expect(before.width, closeTo(48 * 3.75, .001)); + expect( + geometryBefore.realX - geometryBefore.effectiveX, + closeTo(8, .001), + ); + expect( + before.right, + closeTo(tester.getTopLeft(_surface(entry)).dx + + geometryBefore.effectiveX, .001), + ); + + tester.widget(find.byType(Slider)).onChanged!(24); + await tester.pumpAndSettle(); + final after = tester.getRect(_body(entry)); + final geometryAfter = + tester.widget(_surface(entry)).geometry; + expect(after.width, closeTo(before.width, .001)); + expect( + geometryAfter.realX - geometryAfter.effectiveX, + closeTo(4 * 48, .001), + ); + final painter = tester.widget( + find.descendant( + of: _surface(entry), + matching: find.byWidgetPredicate( + (widget) => widget is CustomPaint && + widget.painter is AdjustedDeadlineExtensionPainter, + ), + ), + ); + expect( + (painter.painter! as AdjustedDeadlineExtensionPainter).stripeWidth, + 16, + ); + expect(painter.foregroundPainter, isA()); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('A single-point window still paints both coincident markers', ( + tester, + ) async { + const point = DeadlineCutoffWindow( + enabled: true, + startMinute: 14 * 60, + endMinute: 14 * 60, + ); + final entry = _deadline(tailDays: 14, window: point); + expect(entry.hasDeadlineCutoff, isTrue); + expect(entry.hasAdjustedDeadline, isFalse); + await _showTimeline(tester, [entry], window: point); + final surface = tester.widget(_surface(entry)); + expect(surface.geometry.realX, surface.geometry.effectiveX); + expect(surface.realMarkerColor.a, closeTo(.5, .001)); + expect(tester.getRect(_body(entry)).width, closeTo(48 * 3.75, .001)); + final placement = layoutAgendaTimeline( + [entry], + minimumEventSpan: const Duration(minutes: 1), + ).single; + expect(placement.start, entry.dueAt.subtract(const Duration(days: 4))); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('A direct timeline observes cutoff changes on the same entries', ( + tester, + ) async { + final raw = _deadline(window: const DeadlineCutoffWindow()); + final app = await _showTimeline( + tester, + [raw], + window: const DeadlineCutoffWindow(), + ); + expect(_surface(raw), findsNothing); + final initialRight = tester.getRect(_body(raw)).right; + await app.settings.setDeadlineCutoff(_window); + await tester.pumpAndSettle(); + expect(_surface(raw), findsOneWidget); + expect(tester.getRect(_body(raw)).right, lessThan(initialRight)); + final firstRight = tester.getRect(_body(raw)).right; + await app.settings.setDeadlineCutoff( + _window.copyWith(startMinute: 12 * 60), + ); + await tester.pumpAndSettle(); + expect(tester.getRect(_body(raw)).right, greaterThan(firstRight)); + await app.settings.setDeadlineCutoff(const DeadlineCutoffWindow()); + await tester.pumpAndSettle(); + expect(_surface(raw), findsNothing); + expect(tester.getRect(_body(raw)).right, closeTo(initialRight, .001)); + expect(raw.startsAt, _date.add(const Duration(hours: 14))); + expect(raw.hasAdjustedDeadline, isFalse); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('Settings are authoritative even for already-projected entries', ( + tester, + ) async { + final projected = _deadline(); + await _showTimeline( + tester, + [projected], + window: const DeadlineCutoffWindow(), + ); + expect(_surface(projected), findsNothing); + expect(projected.hasAdjustedDeadline, isTrue); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('Extension-only visibility is bounded and remains tappable', ( + tester, + ) async { + final entry = _deadline( + at: _date.add(const Duration(hours: 8)), + window: const DeadlineCutoffWindow(enabled: true), + ); + await _showTimeline( + tester, + [entry], + window: const DeadlineCutoffWindow(enabled: true), + ); + tester.widget(find.byType(Slider)).onChanged!(24); + await tester.pumpAndSettle(); + final surface = tester.getRect(_surface(entry)); + final body = tester.getRect(_body(entry)); + expect(body.right, lessThan(0)); + expect(surface.width, greaterThan(0)); + expect(surface.width, lessThanOrEqualTo(390)); + await tester.tapAt(surface.center); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + for (final done in [false, true]) { + testWidgets('Real marker and stripe palette are dimmed, done=$done', ( + tester, + ) async { + final entry = _deadline(done: done); + await _showTimeline(tester, [entry]); + final surface = tester.widget(_surface(entry)); + expect(surface.realMarkerColor.a, closeTo(done ? .25 : .5, .001)); + expect(surface.fill.a, closeTo(done ? .5 : 1, .001)); + expect(surface.fill.r, greaterThan(0)); + expect(surface.fill.b, greaterThan(0)); + await tester.pumpWidget(const SizedBox()); + }); + } + + for (final done in [false, true]) { + testWidgets('Stripes keep their palette, angle and alpha, done=$done', ( + tester, + ) async { + final key = GlobalKey(); + final alpha = done ? .5 : 1.0; + await tester.pumpWidget( + MaterialApp( + home: Center( + child: RepaintBoundary( + key: key, + child: SizedBox( + width: 80, + height: 32, + child: CustomPaint( + painter: AdjustedDeadlineExtensionPainter( + effectiveX: 0, + realX: 80, + color: const Color(0xFF336699).withValues(alpha: alpha), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final data = await _pixels(tester, key); + int channel(int x, int y, int channel) => + data.getUint8((y * 80 + x) * 4 + channel); + expect(channel(10, 4, 3), closeTo(255 * alpha * .32, 2)); + expect(channel(26, 4, 3), closeTo(255 * alpha * .16, 2)); + // x+y remains constant when moving along a 45-degree band. + expect(channel(6, 8, 3), channel(10, 4, 3)); + for (final x in [10, 26]) { + expect(channel(x, 4, 0), closeTo(0x33, 3)); + expect(channel(x, 4, 1), closeTo(0x66, 3)); + expect(channel(x, 4, 2), closeTo(0x99, 3)); + } + await tester.pumpWidget(const SizedBox()); + }); + } + + for (final gap in [0.0, 0.2]) { + testWidgets('The real marker stays on top with a $gap pixel gap', ( + tester, + ) async { + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: RepaintBoundary( + key: key, + child: SizedBox( + width: 80, + height: 32, + child: AdjustedDeadlineSurface( + geometry: AdjustedDeadlineGeometry( + bodyLeft: 0, + effectiveX: 40, + realX: 40 + gap, + ), + bodyWidth: 40, + fill: Colors.transparent, + realMarkerColor: Colors.red.withValues(alpha: .5), + semanticsLabel: '真实标线优先', + onTap: () {}, + body: const DecoratedBox( + decoration: BoxDecoration( + border: Border( + right: BorderSide(color: Colors.blue, width: 3), + ), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final data = await _pixels(tester, key); + final pixel = (16 * 80 + 38) * 4; + // Red over blue is purple; blue painted last erases the red component. + expect(data.getUint8(pixel), greaterThan(110)); + expect(data.getUint8(pixel + 2), greaterThan(110)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + } +} diff --git a/test/deadline_cutoff_integration_test.dart b/test/deadline_cutoff_integration_test.dart new file mode 100644 index 0000000..6a5b849 --- /dev/null +++ b/test/deadline_cutoff_integration_test.dart @@ -0,0 +1,288 @@ +import 'dart:convert'; + +import 'package:bitree/agenda/calendar.dart'; +import 'package:bitree/agenda/controller.dart'; +import 'package:bitree/agenda/model.dart'; +import 'package:bitree/agenda/ui/agenda_detail.dart'; +import 'package:bitree/l10n/app_localizations_zh.dart'; +import 'package:bitree/l10n/formats.dart'; +import 'package:bitree/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/test_app.dart'; + +const _window = DeadlineCutoffWindow(enabled: true); +final _real = DateTime.utc(2026, 11, 11, 7); +final _effective = DateTime.utc(2026, 11, 10, 22); +final _previousDay = DateTime.utc(2026, 11, 10); +final _realDay = DateTime.utc(2026, 11, 11); + +AgendaEntry _deadline({ + String id = 'deadline', + DateTime? startsAt, + bool allDay = false, + bool deleted = false, +}) => AgendaEntry( + id: id, + title: '测试作业 $id', + kind: AgendaKind.deadline, + startsAt: startsAt ?? _real, + allDay: allDay, + deleted: deleted, +); + +class _RecordingAgendaStore extends MemoryAgendaStore { + _RecordingAgendaStore(super.entries); + + int writes = 0; + + @override + Future write(List entries) async { + writes++; + await super.write(entries); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('App settings immediately update projection without rewriting deadlines', () async { + final raw = _deadline(); + final store = _RecordingAgendaStore([raw]); + final agenda = AgendaController(store); + final app = TestAppController(agenda: agenda); + await app.initialize(); + var notifications = 0; + agenda.addListener(() => notifications++); + + final writing = app.settings.setDeadlineCutoff(_window); + // The ValueNotifier listener runs before asynchronous preference persistence. + expect(agenda.entries.single.dueAt, _effective); + expect(agenda.entries.single.realDueAt, _real); + expect(agenda.entries.single.startsAt, _real); + expect(notifications, 1); + expect(store.writes, 0); + expect(store.entries.single.startsAt, raw.startsAt); + await writing; + + await app.settings.setDeadlineCutoff(_window); + expect(notifications, 1); + expect(store.writes, 0); + await app.settings.setDeadlineCutoff(_window.copyWith(enabled: false)); + expect(agenda.entries.single.dueAt, _real); + expect(agenda.entries.single.hasDeadlineCutoff, isFalse); + expect(notifications, 2); + expect(store.writes, 0); + }); + + test('Pending dates, ordering and calendar items use effective campus time', () async { + final earlier = _deadline( + id: 'earlier', + startsAt: DateTime.utc(2026, 11, 10, 21), + ); + final event = AgendaEntry( + id: 'event', + title: '晚间日程', + kind: AgendaKind.event, + startsAt: DateTime.utc(2026, 11, 10, 23), + endsAt: DateTime.utc(2026, 11, 11), + ); + final app = TestAppController(entries: [_deadline(), event, earlier]); + await app.initialize(); + final now = DateTime.utc(2026, 11, 10, 20); + expect(app.agenda.pendingOn(_realDay, now: now).map((e) => e.id), ['deadline']); + await app.settings.setDeadlineCutoff(_window); + + final pending = app.agenda.pendingOn(_previousDay, now: now); + expect(pending.map((entry) => entry.id), ['earlier', 'deadline', 'event']); + expect(app.agenda.pendingOn(_realDay, now: now), isEmpty); + final projected = pending.singleWhere((entry) => entry.id == 'deadline'); + expect(projected.occursOn(_previousDay), isTrue); + expect(projected.occursOn(_realDay), isFalse); + expect(projected.overdue(_effective), isFalse); + expect(projected.overdue(_effective.add(const Duration(seconds: 1))), isTrue); + expect(projected.realDueAt.isAfter(_effective), isTrue); + + final calendar = calendarItemsOn(_previousDay, null, app.agenda.entries); + expect(calendar.map((item) => item.entry!.id), ['earlier', 'deadline', 'event']); + expect( + calendar.singleWhere((item) => item.entry!.id == 'deadline').startsAt, + _effective, + ); + expect(calendarItemsOn(_realDay, null, app.agenda.entries), isEmpty); + await app.settings.setDeadlineCutoff(_window.copyWith(enabled: false)); + expect(app.agenda.entries.first.dueAt, _real); + expect( + calendarItemsOn(_realDay, null, app.agenda.entries).single.startsAt, + _real, + ); + }); + + test('Changing and disabling settings reprojects entries and tombstones from raw data', () async { + final store = _RecordingAgendaStore([ + _deadline(), + _deadline(id: 'deleted', deleted: true), + ]); + final app = TestAppController(agenda: AgendaController(store)); + await app.initialize(); + await app.settings.setDeadlineCutoff(_window); + expect(app.agenda.entries.single.dueAt, _effective); + expect(app.agenda.deletedDeadlines.single.dueAt, _effective); + const morning = DeadlineCutoffWindow( + enabled: true, + startMinute: 6 * 60, + endMinute: 8 * 60, + ); + await app.settings.setDeadlineCutoff(morning); + final morningDue = DateTime.utc(2026, 11, 11, 6); + expect(app.agenda.entries.single.dueAt, morningDue); + expect(app.agenda.deletedDeadlines.single.dueAt, morningDue); + await app.settings.setDeadlineCutoff(_window); + expect(app.agenda.entries.single.dueAt, _effective); + await app.settings.setDeadlineCutoff(_window.copyWith(enabled: false)); + expect(app.agenda.entries.single.dueAt, _real); + expect(app.agenda.deletedDeadlines.single.dueAt, _real); + expect(store.writes, 0); + expect(store.entries.every((entry) => entry.startsAt == _real), isTrue); + }); + + test('Completion, deletion and restoration persist actual timestamps only', () async { + final store = _RecordingAgendaStore([_deadline()]); + final app = TestAppController(agenda: AgendaController(store)); + await app.initialize(); + await app.settings.setDeadlineCutoff(_window); + + final shown = app.agenda.entries.single; + expect(await app.agenda.save(shown.copyWith(done: true)), isTrue); + expect(app.agenda.entries.single.done, isTrue); + expect(app.agenda.entries.single.dueAt, _effective); + expect(app.agenda.entries.single.overdue(DateTime.utc(2030)), isFalse); + expect(store.entries.single.startsAt, _real); + expect(await app.agenda.delete(app.agenda.entries.single), isTrue); + expect(app.agenda.entries, isEmpty); + final deleted = app.agenda.deletedDeadlines.single; + expect(deleted.dueAt, _effective); + expect(deleted.deleted, isTrue); + + final savedJson = jsonDecode(jsonEncode(store.entries.single.toJson())) + as Map; + expect(savedJson['startsAt'], _real.toIso8601String()); + expect(savedJson['allDay'], isFalse); + expect(savedJson['done'], isTrue); + expect(savedJson['deleted'], isTrue); + expect(savedJson.containsKey('deadlineCutoffWindow'), isFalse); + expect(savedJson.containsKey('dueAt'), isFalse); + final restoredFromDisk = AgendaEntry.fromJson(savedJson); + expect(restoredFromDisk.dueAt, _real); + expect(restoredFromDisk.deadlineCutoffWindow.enabled, isFalse); + + expect(await app.agenda.restore(deleted), isTrue); + expect(app.agenda.deletedDeadlines, isEmpty); + expect(app.agenda.entries.single.startsAt, _real); + expect(app.agenda.entries.single.done, isTrue); + await app.settings.setDeadlineCutoff(_window.copyWith(enabled: false)); + expect(app.agenda.entries.single.dueAt, _real); + }); + + test('All-day persistence retains the selected date and raw allDay flag', () async { + final raw = _deadline(startsAt: _previousDay, allDay: true); + final store = _RecordingAgendaStore([raw]); + final app = TestAppController(agenda: AgendaController(store)); + await app.initialize(); + await app.settings.setDeadlineCutoff(_window); + final shown = app.agenda.entries.single; + expect(shown.realDueAt, _realDay); + expect(shown.dueAt, _effective); + expect(shown.scheduledAt, _effective); + expect(shown.effectiveAllDay, isFalse); + expect(shown.allDay, isTrue); + expect(await app.agenda.save(shown.copyWith(done: true)), isTrue); + final json = store.entries.single.toJson(); + expect(json['startsAt'], _previousDay.toIso8601String()); + expect(json['allDay'], isTrue); + final disk = AgendaEntry.fromJson(json); + expect(disk.scheduledAt, _previousDay); + expect(disk.realDueAt, _realDay); + expect(disk.effectiveAllDay, isTrue); + await app.settings.setDeadlineCutoff(_window.copyWith(enabled: false)); + expect(app.agenda.entries.single.scheduledAt, _previousDay); + expect(app.agenda.entries.single.dueAt, _realDay); + expect(app.agenda.entries.single.effectiveAllDay, isTrue); + }); + + test('Saved settings project raw entries on initialization and reload', () async { + final preferences = MemoryPreferences({ + 'deadlineCutoff': jsonEncode(_window.toJson()), + }); + final store = _RecordingAgendaStore([_deadline()]); + final agenda = AgendaController(store); + final app = TestAppController(agenda: agenda, preferences: preferences); + await app.initialize(); + expect(agenda.entries.single.dueAt, _effective); + expect(store.writes, 0); + await agenda.load(); + expect(agenda.entries.single.dueAt, _effective); + expect(agenda.entries.single.realDueAt, _real); + expect(store.writes, 0); + await app.settings.setDeadlineCutoff(_window.copyWith(enabled: false)); + + final reopened = TestAppController( + agenda: AgendaController(store), + preferences: preferences, + ); + await reopened.initialize(); + expect(reopened.settings.deadlineCutoff.value.enabled, isFalse); + expect(reopened.agenda.entries.single.dueAt, _real); + expect(reopened.agenda.entries.single.startsAt, _real); + }); + + testWidgets('The open detail sheet shows both effective and original deadlines', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final app = TestAppController(entries: [_deadline()]); + await app.initialize(); + await app.settings.setDeadlineCutoff(_window); + await tester.pumpWidget(MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: TextButton( + onPressed: () => showAgendaEntry( + context, + app, + app.agenda.entries.single, + ), + child: const Text('打开详情'), + ), + ), + ), + ), + )); + await tester.tap(find.text('打开详情')); + await tester.pumpAndSettle(); + final l10n = AppLocalizationsZh(); + expect( + find.text('22:00 ${l10n.agendaAdjustedDeadline}', findRichText: true), + findsOneWidget, + ); + final originalLabel = l10n.agendaCutoffOriginal( + localizedAgendaDateTime(l10n, _real), + ); + expect(find.text(originalLabel), findsOneWidget); + await app.settings.setDeadlineCutoff(_window.copyWith(enabled: false)); + await tester.pumpAndSettle(); + expect(find.text(originalLabel), findsNothing); + expect( + find.text('07:00 ${l10n.due}', findRichText: true), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); +} diff --git a/test/deadline_cutoff_settings_test.dart b/test/deadline_cutoff_settings_test.dart new file mode 100644 index 0000000..119f8e9 --- /dev/null +++ b/test/deadline_cutoff_settings_test.dart @@ -0,0 +1,373 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:bitree/agenda/deadline_cutoff.dart'; +import 'package:bitree/app/settings_controller.dart'; +import 'package:bitree/app/ui/deadline_cutoff_settings.dart'; +import 'package:bitree/app/ui/settings_screen.dart'; +import 'package:bitree/l10n/app_localizations.dart'; +import 'package:bitree/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/test_app.dart'; + +const _key = 'deadlineCutoff'; + +class _DelayedReadPreferences extends MemoryPreferences { + final started = Completer(); + final result = Completer(); + + @override + Future getString(String key) { + if (key != _key) return super.getString(key); + if (!started.isCompleted) started.complete(); + return result.future; + } +} + +class _DelayedWritePreferences extends MemoryPreferences { + final started = Completer(); + final release = Completer(); + final writes = []; + + @override + Future setString(String key, String value) async { + if (key != _key) return super.setString(key, value); + writes.add(value); + if (writes.length == 1) { + started.complete(); + await release.future; + } + await super.setString(key, value); + } +} + +class _FailOncePreferences extends MemoryPreferences { + bool failNext = true; + + @override + Future setString(String key, String value) async { + if (key == _key && failNext) { + failNext = false; + throw Exception('simulated preference write failure'); + } + await super.setString(key, value); + } +} + +class _FailReadPreferences extends MemoryPreferences { + @override + Future getString(String key) async { + if (key == _key) throw Exception('simulated preference read failure'); + return super.getString(key); + } +} + +AppSettingsController _settings(MemoryPreferences preferences) => + AppSettingsController(preferences, coursePaletteLoader: () async => {}); + +Future _showCutoff( + WidgetTester tester, + AppSettingsController settings, { + Locale locale = const Locale('zh'), + double scale = 1, + Size size = const Size(390, 844), +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget( + MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: bitreeTheme(Brightness.light), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(scale), + alwaysUse24HourFormat: true, + ), + child: child!, + ), + home: DeadlineCutoffScreen(settings: settings), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Cutoff defaults to disabled and retains the overnight preset', () async { + final preferences = MemoryPreferences(); + final settings = _settings(preferences); + addTearDown(settings.dispose); + expect(settings.deadlineCutoff.value, const DeadlineCutoffWindow()); + await settings.initialize(); + expect(settings.deadlineCutoff.value.enabled, isFalse); + expect(settings.deadlineCutoff.value.startMinute, 22 * 60); + expect(settings.deadlineCutoff.value.endMinute, 8 * 60); + expect(preferences.value(_key), isNull); + }); + + test('A saved cutoff is restored and a disabled choice survives restart', () async { + const saved = DeadlineCutoffWindow( + enabled: true, + startMinute: 21 * 60 + 30, + endMinute: 7 * 60 + 15, + ); + final preferences = MemoryPreferences({_key: jsonEncode(saved.toJson())}); + final first = _settings(preferences); + addTearDown(first.dispose); + await first.initialize(); + expect(first.deadlineCutoff.value, saved); + var settingNotifications = 0; + var valueNotifications = 0; + first.addListener(() => settingNotifications++); + first.deadlineCutoff.addListener(() => valueNotifications++); + + final disabled = saved.copyWith(enabled: false); + final writing = first.setDeadlineCutoff(disabled); + expect(first.deadlineCutoff.value, disabled); + expect(settingNotifications, 1); + expect(valueNotifications, 1); + await writing; + expect(jsonDecode(preferences.value(_key)!), disabled.toJson()); + + final restarted = _settings(preferences); + addTearDown(restarted.dispose); + await restarted.initialize(); + expect(restarted.deadlineCutoff.value, disabled); + expect(restarted.deadlineCutoff.value.startMinute, saved.startMinute); + expect(restarted.deadlineCutoff.value.endMinute, saved.endMinute); + }); + + test('Rapid cutoff changes save in order with the newest value last', () async { + final preferences = _DelayedWritePreferences(); + final settings = _settings(preferences); + addTearDown(settings.dispose); + await settings.initialize(); + const enabled = DeadlineCutoffWindow(enabled: true); + final first = settings.setDeadlineCutoff(enabled); + await preferences.started.future; + final changed = enabled.copyWith(startMinute: 20 * 60); + final second = settings.setDeadlineCutoff(changed); + final disabled = changed.copyWith(enabled: false); + final last = settings.setDeadlineCutoff(disabled); + expect(settings.deadlineCutoff.value, disabled); + expect(preferences.writes, hasLength(1)); + preferences.release.complete(); + await Future.wait([first, second, last]); + expect( + preferences.writes.map(jsonDecode).toList(), + [enabled.toJson(), changed.toJson(), disabled.toJson()], + ); + expect(jsonDecode(preferences.value(_key)!), disabled.toJson()); + }); + + test('A late initialize read cannot replace a newer cutoff choice', () async { + final preferences = _DelayedReadPreferences(); + final settings = _settings(preferences); + addTearDown(settings.dispose); + final loading = settings.initialize(); + await preferences.started.future; + const chosen = DeadlineCutoffWindow(enabled: true, startMinute: 600); + await settings.setDeadlineCutoff(chosen); + preferences.result.complete(jsonEncode(const DeadlineCutoffWindow().toJson())); + await loading; + expect(settings.deadlineCutoff.value, chosen); + expect(jsonDecode(preferences.value(_key)!), chosen.toJson()); + }); + + test('A pending cutoff read does not publish after disposal', () async { + final preferences = _DelayedReadPreferences(); + final settings = _settings(preferences); + final loading = settings.initialize(); + await preferences.started.future; + settings.dispose(); + preferences.result.complete( + jsonEncode(const DeadlineCutoffWindow(enabled: true).toJson()), + ); + await expectLater(loading, completes); + await expectLater( + settings.setDeadlineCutoff(const DeadlineCutoffWindow(enabled: true)), + completes, + ); + expect(preferences.value(_key), isNull); + }); + + for (final invalid in [ + 'not-json', + '[]', + '{"enabled":true,"startMinute":1440,"endMinute":480}', + '{"enabled":"yes","startMinute":1320,"endMinute":480}', + ]) { + test('Invalid cutoff data falls back to disabled: $invalid', () async { + final settings = _settings(MemoryPreferences({_key: invalid})); + addTearDown(settings.dispose); + await settings.initialize(); + expect(settings.deadlineCutoff.value, const DeadlineCutoffWindow()); + }); + } + + test('A cutoff read failure leaves the disabled preset usable', () async { + final settings = _settings(_FailReadPreferences()); + addTearDown(settings.dispose); + await settings.initialize(); + expect(settings.deadlineCutoff.value, const DeadlineCutoffWindow()); + await settings.setDeadlineCutoff(const DeadlineCutoffWindow(enabled: true)); + expect(settings.deadlineCutoff.value.enabled, isTrue); + }); + + test('A failed cutoff write does not poison later queued saves', () async { + final preferences = _FailOncePreferences(); + final settings = _settings(preferences); + addTearDown(settings.dispose); + await settings.initialize(); + const enabled = DeadlineCutoffWindow(enabled: true); + await expectLater(settings.setDeadlineCutoff(enabled), completes); + expect(settings.deadlineCutoff.value, enabled); + final latest = enabled.copyWith(endMinute: 600); + await settings.setDeadlineCutoff(latest); + expect(jsonDecode(preferences.value(_key)!), latest.toJson()); + }); + + for (final locale in [const Locale('zh'), const Locale('en')]) { + testWidgets('Cutoff settings explain every interval mode in ${locale.languageCode}', ( + tester, + ) async { + final preferences = MemoryPreferences(); + final settings = _settings(preferences); + addTearDown(settings.dispose); + await settings.initialize(); + await _showCutoff( + tester, + settings, + locale: locale, + size: const Size(320, 640), + scale: 1.8, + ); + final context = tester.element(find.byType(DeadlineCutoffScreen)); + final l10n = AppLocalizations.of(context); + expect(find.text(l10n.settingsDeadlineCutoff), findsOneWidget); + expect( + tester.widget( + find.byKey(const ValueKey('deadline-cutoff-enabled')), + ).value, + isFalse, + ); + expect(find.text(l10n.deadlineCutoffOvernight('22:00', '08:00')), findsOneWidget); + final switcher = find.byKey(const ValueKey('deadline-cutoff-enabled')); + await tester.ensureVisible(switcher); + await tester.tap(switcher); + await tester.pumpAndSettle(); + expect(settings.deadlineCutoff.value.enabled, isTrue); + await tester.tap(switcher); + await tester.pumpAndSettle(); + expect(settings.deadlineCutoff.value.enabled, isFalse); + expect(settings.deadlineCutoff.value.startMinute, 22 * 60); + expect(settings.deadlineCutoff.value.endMinute, 8 * 60); + + await settings.setDeadlineCutoff( + const DeadlineCutoffWindow(startMinute: 600, endMinute: 720), + ); + await tester.pumpAndSettle(); + expect(find.text(l10n.deadlineCutoffSameDay('10:00', '12:00')), findsOneWidget); + await settings.setDeadlineCutoff( + const DeadlineCutoffWindow(startMinute: 600, endMinute: 600), + ); + await tester.pumpAndSettle(); + expect(find.text(l10n.deadlineCutoffInstant('10:00')), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + } + + testWidgets('Canceling either time picker does not persist a cutoff', ( + tester, + ) async { + final preferences = MemoryPreferences(); + final settings = _settings(preferences); + addTearDown(settings.dispose); + await settings.initialize(); + await _showCutoff(tester, settings); + for (final name in ['deadline-cutoff-start', 'deadline-cutoff-end']) { + final tile = find.byKey(ValueKey(name)); + await tester.ensureVisible(tile); + await tester.tap(tile); + await tester.pumpAndSettle(); + expect(find.byType(TimePickerDialog), findsOneWidget); + final pickerContext = tester.element(find.byType(TimePickerDialog)); + await tester.tap(find.text(MaterialLocalizations.of(pickerContext).cancelButtonLabel)); + await tester.pumpAndSettle(); + expect(find.byType(TimePickerDialog), findsNothing); + expect(settings.deadlineCutoff.value, const DeadlineCutoffWindow()); + expect(preferences.value(_key), isNull); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('Time picker changes only the selected endpoint and saves it', ( + tester, + ) async { + final preferences = MemoryPreferences(); + final settings = _settings(preferences); + addTearDown(settings.dispose); + await settings.initialize(); + await _showCutoff(tester, settings); + final tile = find.byKey(const ValueKey('deadline-cutoff-start')); + await tester.tap(tile); + await tester.pumpAndSettle(); + final inputs = find.descendant( + of: find.byType(TimePickerDialog), + matching: find.byType(TextField), + ); + expect(inputs, findsNWidgets(2)); + // Preserve changes made while the picker is open, not its old snapshot. + await settings.setDeadlineCutoff( + const DeadlineCutoffWindow(enabled: true, endMinute: 600), + ); + await tester.pump(); + await tester.enterText(inputs.first, '21'); + await tester.enterText(inputs.last, '15'); + final pickerContext = tester.element(find.byType(TimePickerDialog)); + await tester.tap(find.text(MaterialLocalizations.of(pickerContext).okButtonLabel)); + await tester.pumpAndSettle(); + expect(settings.deadlineCutoff.value.startMinute, 21 * 60 + 15); + expect(settings.deadlineCutoff.value.endMinute, 600); + expect(settings.deadlineCutoff.value.enabled, isTrue); + expect(jsonDecode(preferences.value(_key)!), settings.deadlineCutoff.value.toJson()); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('Settings expose cutoff without a timetable or school account', ( + tester, + ) async { + final app = TestAppController(); + await app.initialize(); + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: SettingsScreen( + controller: app, + checkingForUpdates: false, + onCheckForUpdates: () {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final entry = find.byKey(const ValueKey('deadline-cutoff-settings')); + await tester.ensureVisible(entry); + await tester.tap(entry); + await tester.pumpAndSettle(); + expect(find.byType(DeadlineCutoffScreen), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); +} diff --git a/test/deadline_cutoff_test.dart b/test/deadline_cutoff_test.dart new file mode 100644 index 0000000..569f00a --- /dev/null +++ b/test/deadline_cutoff_test.dart @@ -0,0 +1,339 @@ +import 'dart:convert'; + +import 'package:bitree/agenda/model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _overnight = DeadlineCutoffWindow(enabled: true); +const _daytime = DeadlineCutoffWindow( + enabled: true, + startMinute: 9 * 60, + endMinute: 17 * 60, +); + +AgendaEntry _deadline(DateTime at, { + bool allDay = false, + bool startOnly = false, +}) => AgendaEntry( + id: 'deadline', + title: '作业', + kind: AgendaKind.deadline, + startsAt: at, + allDay: allDay, + startOnly: startOnly, +); + +void main() { + group('Daily cutoff window', () { + test('Disabled windows leave every instant unchanged', () { + const disabled = DeadlineCutoffWindow(); + for (final actual in [ + DateTime.utc(2026, 11, 11, 0), + DateTime.utc(2026, 11, 11, 8), + DateTime.utc(2026, 11, 11, 22), + DateTime.utc(2026, 11, 11, 23, 59, 59, 999, 999), + ]) { + expect(disabled.contains(actual), isFalse); + expect(disabled.apply(actual), actual); + } + }); + + test('Same-day windows include both exact endpoints', () { + final start = DateTime.utc(2026, 11, 11, 9); + final end = DateTime.utc(2026, 11, 11, 17); + expect(_daytime.crossesMidnight, isFalse); + expect(_daytime.contains(start), isTrue); + expect(_daytime.apply(start), start); + expect(_daytime.contains(end), isTrue); + expect(_daytime.apply(end), start); + expect(_daytime.apply(DateTime.utc(2026, 11, 11, 12, 30)), start); + }); + + test('Containment compares exact instants, not truncated minutes', () { + final start = DateTime.utc(2026, 11, 11, 9); + final end = DateTime.utc(2026, 11, 11, 17); + for (final outside in [ + start.subtract(const Duration(microseconds: 1)), + end.add(const Duration(microseconds: 1)), + end.add(const Duration(milliseconds: 1)), + end.add(const Duration(seconds: 1)), + DateTime.utc(2026, 11, 11, 18), + ]) { + expect(_daytime.contains(outside), isFalse); + expect(_daytime.apply(outside), outside); + } + expect( + _daytime.apply(end.subtract(const Duration(microseconds: 1))), + start, + ); + }); + + test('Overnight early hours belong to the previous calendar day', () { + final previousStart = DateTime.utc(2026, 11, 10, 22); + final todayStart = DateTime.utc(2026, 11, 11, 22); + expect(_overnight.crossesMidnight, isTrue); + for (final actual in [ + DateTime.utc(2026, 11, 11), + DateTime.utc(2026, 11, 11, 7, 30), + DateTime.utc(2026, 11, 11, 8), + ]) { + expect(_overnight.contains(actual), isTrue); + expect(_overnight.apply(actual), previousStart); + } + expect(_overnight.apply(todayStart), todayStart); + expect( + _overnight.apply(DateTime.utc(2026, 11, 11, 23, 59)), + todayStart, + ); + for (final outside in [ + DateTime.utc(2026, 11, 11, 8, 0, 0, 0, 1), + DateTime.utc(2026, 11, 11, 8, 0, 1), + todayStart.subtract(const Duration(microseconds: 1)), + DateTime.utc(2026, 11, 11, 12), + ]) { + expect(_overnight.contains(outside), isFalse); + expect(_overnight.apply(outside), outside); + } + }); + + test('Overnight windows cross month, year and leap-day boundaries', () { + for (final (actual, expected) in [ + (DateTime.utc(2027, 1, 1, 7), DateTime.utc(2026, 12, 31, 22)), + (DateTime.utc(2026, 12, 1, 7), DateTime.utc(2026, 11, 30, 22)), + (DateTime.utc(2028, 3, 1, 7), DateTime.utc(2028, 2, 29, 22)), + ]) { + expect(_overnight.apply(actual), expected); + expect(_overnight.apply(actual).isUtc, isTrue); + } + }); + + test('Equal times match just one exact instant, not the full day', () { + const point = DeadlineCutoffWindow( + enabled: true, + startMinute: 8 * 60, + endMinute: 8 * 60, + ); + final at = DateTime.utc(2026, 11, 11, 8); + expect(point.isSinglePoint, isTrue); + expect(point.crossesMidnight, isFalse); + expect(point.contains(at), isTrue); + expect(point.apply(at), at); + for (final outside in [ + at.subtract(const Duration(microseconds: 1)), + at.add(const Duration(microseconds: 1)), + DateTime.utc(2026, 11, 11), + DateTime.utc(2026, 11, 11, 22), + ]) { + expect(point.contains(outside), isFalse); + expect(point.apply(outside), outside); + } + }); + + test('Midnight equal-time windows still match a single point', () { + const point = DeadlineCutoffWindow( + enabled: true, + startMinute: 0, + endMinute: 0, + ); + final at = DateTime.utc(2027, 1, 1); + expect(point.contains(at), isTrue); + expect(point.apply(at), at); + expect(point.contains(at.add(const Duration(microseconds: 1))), isFalse); + expect(point.contains(at.subtract(const Duration(microseconds: 1))), isFalse); + }); + + test('Serialization, equality and copies preserve the selected window', () { + const original = DeadlineCutoffWindow( + enabled: true, + startMinute: 21 * 60 + 15, + endMinute: 7 * 60 + 45, + ); + final json = jsonDecode(jsonEncode(original.toJson())) + as Map; + final restored = DeadlineCutoffWindow.fromJson(json); + expect(restored, original); + expect(restored.hashCode, original.hashCode); + expect(original.copyWith(), original); + final disabled = original.copyWith(enabled: false); + expect(disabled.enabled, isFalse); + expect(disabled.startMinute, original.startMinute); + expect(disabled.endMinute, original.endMinute); + expect(disabled, isNot(original)); + expect(original.copyWith(startMinute: 123).startMinute, 123); + expect(original.copyWith(endMinute: 456).endMinute, 456); + }); + + test('Stored minute ranges outside a single day are rejected', () { + for (final field in ['startMinute', 'endMinute']) { + for (final invalid in [-1, 24 * 60, 24 * 60 + 1]) { + final json = _overnight.toJson()..[field] = invalid; + expect( + () => DeadlineCutoffWindow.fromJson(json), + throwsA(isA()), + ); + } + } + }); + + test('Malformed stored types and missing fields are rejected', () { + for (final json in >[ + {}, + {'enabled': true, 'startMinute': 60}, + {'enabled': 'true', 'startMinute': 60, 'endMinute': 120}, + {'enabled': true, 'startMinute': '60', 'endMinute': 120}, + {'enabled': true, 'startMinute': 60, 'endMinute': 120.5}, + ]) { + expect( + () => DeadlineCutoffWindow.fromJson(json), + throwsA(isA()), + ); + } + }); + }); + + group('Agenda deadline projection', () { + test('Effective time changes but the true deadline remains untouched', () { + final actual = DateTime.utc(2026, 11, 11, 7); + final raw = _deadline(actual); + final shown = raw.withDeadlineCutoff(_overnight); + expect(raw.dueAt, actual); + expect(raw.hasDeadlineCutoff, isFalse); + expect(shown.startsAt, actual); + expect(shown.realDueAt, actual); + expect(shown.dueAt, DateTime.utc(2026, 11, 10, 22)); + expect(shown.scheduledAt, shown.dueAt); + expect(shown.hasDeadlineCutoff, isTrue); + expect(shown.hasAdjustedDeadline, isTrue); + expect(shown.occursOn(DateTime.utc(2026, 11, 10)), isTrue); + expect(shown.occursOn(DateTime.utc(2026, 11, 11)), isFalse); + expect(shown.overdue(shown.dueAt), isFalse); + expect( + shown.overdue(shown.dueAt.add(const Duration(microseconds: 1))), + isTrue, + ); + expect(raw.overdue(shown.dueAt.add(const Duration(hours: 1))), isFalse); + }); + + test('A matched start or single point has overlapping markers, not a shift', () { + final actual = DateTime.utc(2026, 11, 11, 22); + for (final window in [ + _overnight, + const DeadlineCutoffWindow( + enabled: true, + startMinute: 22 * 60, + endMinute: 22 * 60, + ), + ]) { + final shown = _deadline(actual).withDeadlineCutoff(window); + expect(shown.hasDeadlineCutoff, isTrue); + expect(shown.hasAdjustedDeadline, isFalse); + expect(shown.dueAt, shown.realDueAt); + expect(shown.scheduledAt, actual); + } + }); + + test('All-day true midnight derives a timed cutoff without changing raw day', () { + final rawDay = DateTime.utc(2026, 11, 10); + final raw = _deadline(rawDay, allDay: true); + final shown = raw.withDeadlineCutoff(_overnight); + expect(shown.realDueAt, DateTime.utc(2026, 11, 11)); + expect(shown.dueAt, DateTime.utc(2026, 11, 10, 22)); + expect(shown.scheduledAt, shown.dueAt); + expect(shown.startsAt, rawDay); + expect(shown.allDay, isTrue); + expect(shown.effectiveAllDay, isFalse); + final restored = shown.withDeadlineCutoff(const DeadlineCutoffWindow()); + expect(restored.realDueAt, shown.realDueAt); + expect(restored.dueAt, shown.realDueAt); + expect(restored.scheduledAt, rawDay); + expect(restored.effectiveAllDay, isTrue); + }); + + test('Unadjusted all-day deadlines keep their original day grouping', () { + final raw = _deadline(DateTime.utc(2026, 11, 10), allDay: true); + final outside = raw.withDeadlineCutoff(_daytime); + expect(outside.hasDeadlineCutoff, isFalse); + expect(outside.scheduledAt, raw.startsAt); + expect(outside.effectiveAllDay, isTrue); + final exactMidnight = raw.withDeadlineCutoff(const DeadlineCutoffWindow( + enabled: true, + startMinute: 0, + endMinute: 0, + )); + expect(exactMidnight.hasDeadlineCutoff, isTrue); + expect(exactMidnight.hasAdjustedDeadline, isFalse); + expect(exactMidnight.scheduledAt, raw.startsAt); + expect(exactMidnight.effectiveAllDay, isTrue); + }); + + test('Window changes always derive from real time and never accumulate', () { + final actual = DateTime.utc(2026, 11, 11, 7); + final raw = _deadline(actual); + final first = raw.withDeadlineCutoff(_overnight); + const changed = DeadlineCutoffWindow( + enabled: true, + startMinute: 6 * 60, + endMinute: 8 * 60, + ); + final second = first.withDeadlineCutoff(changed); + expect(second.dueAt, DateTime.utc(2026, 11, 11, 6)); + expect(second.startsAt, actual); + expect(second.realDueAt, actual); + expect(second.withDeadlineCutoff(_overnight).dueAt, first.dueAt); + expect(second.withDeadlineCutoff(_daytime).dueAt, actual); + final disabled = second.withDeadlineCutoff(changed.copyWith(enabled: false)); + expect(disabled.dueAt, actual); + expect(disabled.scheduledAt, actual); + expect(first.withDeadlineCutoff(_overnight), same(first)); + }); + + test('Copying completion or deletion preserves the projection and raw JSON', () { + final raw = _deadline(DateTime.utc(2026, 11, 11, 7), allDay: true); + final shown = raw.withDeadlineCutoff(_overnight); + final completed = shown.copyWith(done: true, deleted: true); + expect(completed.deadlineCutoffWindow, _overnight); + expect(completed.dueAt, shown.dueAt); + expect(completed.realDueAt, raw.realDueAt); + expect(completed.startsAt, raw.startsAt); + expect(completed.allDay, raw.allDay); + expect(completed.overdue(DateTime.utc(2030)), isFalse); + final json = completed.toJson(); + expect(json['startsAt'], raw.startsAt.toIso8601String()); + expect(json['allDay'], isTrue); + expect(json['done'], isTrue); + expect(json['deleted'], isTrue); + expect(json.containsKey('deadlineCutoffWindow'), isFalse); + expect(json.containsKey('dueAt'), isFalse); + final restored = AgendaEntry.fromJson(json); + expect(restored.deadlineCutoffWindow.enabled, isFalse); + expect(restored.dueAt, raw.realDueAt); + expect(restored.withDeadlineCutoff(_overnight).dueAt, shown.dueAt); + expect(restored.done, isTrue); + expect(restored.deleted, isTrue); + }); + + test('Events, exams and start-only quizzes ignore the cutoff', () { + final at = DateTime.utc(2026, 11, 11, 7); + final unaffected = [ + for (final kind in [AgendaKind.event, AgendaKind.exam]) + AgendaEntry( + id: kind.name, + title: kind.name, + kind: kind, + startsAt: at, + endsAt: at.add(const Duration(hours: 2)), + ), + _deadline(at, startOnly: true), + ]; + for (final raw in unaffected) { + final shown = raw.withDeadlineCutoff(_overnight); + expect(shown.startsAt, raw.startsAt); + expect(shown.endsAt, raw.endsAt); + expect(shown.dueAt, raw.realDueAt); + expect(shown.scheduledAt, raw.startsAt); + expect(shown.hasDeadlineCutoff, isFalse); + expect(shown.hasAdjustedDeadline, isFalse); + expect(shown.overdue(DateTime.utc(2030)), isFalse); + } + }); + }); +} From 35c6aff44a915ddee4b1ea98303180947156903a Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Mon, 9 Nov 2026 12:33:10 +0800 Subject: [PATCH 02/24] fix(agenda): shorten adjusted deadline label and test timetable badges --- lib/l10n/app_localizations_zh.dart | 2 +- lib/l10n/app_zh.arb | 2 +- test/agenda_adjusted_timeline_test.dart | 16 ++ test/deadline_cutoff_badge_test.dart | 256 +++++++++++++++++++++ test/deadline_cutoff_integration_test.dart | 4 +- test/deadline_cutoff_settings_test.dart | 5 +- 6 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 test/deadline_cutoff_badge_test.dart diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 12d9c57..70d6086 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2383,7 +2383,7 @@ class AppLocalizationsZh extends AppLocalizations { String get deadlineCutoffPreserveHint => '真实截止时间保持不变。关闭后不再伪提前,但会保留已选择的开始和结束时间。'; @override - String get agendaAdjustedDeadline => '伪截止时间'; + String get agendaAdjustedDeadline => '伪截止'; @override String get agendaRealDeadline => '真实截止时间'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 5e17abe..3fc1e63 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -696,7 +696,7 @@ "deadlineCutoffInstant": "仅每天 {time} 这一时刻(不是全天)", "deadlineCutoffBoundaryHint": "开始和结束时刻均包含在区间内。结束早于开始时表示跨日;两者相同时仅匹配单个时刻。", "deadlineCutoffPreserveHint": "真实截止时间保持不变。关闭后不再伪提前,但会保留已选择的开始和结束时间。", - "agendaAdjustedDeadline": "伪截止时间", + "agendaAdjustedDeadline": "伪截止", "agendaRealDeadline": "真实截止时间", "agendaCutoffOriginal": "真实截止:{time}", "agendaCutoffAdjusted": "伪截止:{time}", diff --git a/test/agenda_adjusted_timeline_test.dart b/test/agenda_adjusted_timeline_test.dart index 1e9ad83..80de197 100644 --- a/test/agenda_adjusted_timeline_test.dart +++ b/test/agenda_adjusted_timeline_test.dart @@ -142,6 +142,22 @@ void main() { } }); + testWidgets('Adjusted DDL cards use the compact Chinese deadline label', ( + tester, + ) async { + await _showTimeline(tester, [_deadline()]); + expect( + find.descendant( + of: find.byType(AgendaTimelineEntry), + matching: find.text('10:00 伪截止'), + ), + findsOneWidget, + ); + expect(find.textContaining('伪截止时间'), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + testWidgets('The fixed body ends at effective due; the extension zooms', ( tester, ) async { diff --git a/test/deadline_cutoff_badge_test.dart b/test/deadline_cutoff_badge_test.dart new file mode 100644 index 0000000..a852e80 --- /dev/null +++ b/test/deadline_cutoff_badge_test.dart @@ -0,0 +1,256 @@ +import 'package:bitree/agenda/model.dart'; +import 'package:bitree/agenda/ui/agenda_list_tile.dart'; +import 'package:bitree/l10n/app_localizations_zh.dart'; +import 'package:bitree/schedule/ui/timetable_header.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 _monday = DateTime.utc(2026, 11, 9); +final _tuesday = DateTime.utc(2026, 11, 10); +final _wednesday = DateTime.utc(2026, 11, 11); +final _now = DateTime.utc(2026, 11, 10, 23); +final _l10n = AppLocalizationsZh(); +const _overnight = DeadlineCutoffWindow(enabled: true); + +AgendaEntry _deadline( + String id, + int hour, { + bool done = false, + bool deleted = false, + bool startOnly = false, +}) => AgendaEntry( + id: id, + title: '作业 $id', + kind: AgendaKind.deadline, + startsAt: _wednesday.add(Duration(hours: hour)), + done: done, + deleted: deleted, + startOnly: startOnly, +); + +List _deadlines() => [ + _deadline('morning-07', 7), + _deadline('morning-08', 8), + _deadline('midday', 12), +]; + +/// HomeScreen rebuilds on AgendaController notifications. Keep that same +/// lifecycle here while rendering the real header and opening its real drawer. +Future _showHeader( + WidgetTester tester, + List entries, +) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final app = TestAppController(entries: entries); + await app.initialize(); + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: ListenableBuilder( + listenable: app.agenda, + builder: (context, _) => Column( + children: [ + TimetableHeader( + controller: app, + monday: _monday, + now: _now, + gutterWidth: 40, + todayBackground: Colors.transparent, + ), + const Expanded(child: SizedBox()), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + return app; +} + +Finder _badges() => find.descendant( + of: find.byType(TimetableHeader, skipOffstage: false), + matching: find.byType(Badge, skipOffstage: false), + skipOffstage: false, +); + +void _expectCounts(WidgetTester tester, List expected) { + final badges = tester.widgetList(_badges()).toList(); + expect(badges, hasLength(7)); + expect(expected, hasLength(7)); + for (var index = 0; index < expected.length; index++) { + expect( + badges[index].isLabelVisible, + expected[index] > 0, + reason: 'Badge visibility for weekday ${index + 1}', + ); + expect( + (badges[index].label! as Text).data, + '${expected[index]}', + reason: 'Rendered badge count for weekday ${index + 1}', + ); + } +} + +Future _openDay(WidgetTester tester, int weekdayIndex) async { + await tester.tap(_badges().at(weekdayIndex)); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsOneWidget); + expect(tester.takeException(), isNull); +} + +List _dayRows(WidgetTester tester) => tester + .widgetList( + find.descendant( + of: find.byType(BottomSheet), + matching: find.byType(AgendaListTile), + ), + ) + .toList(); + +Future _closeDay(WidgetTester tester) async { + await tester.tap(find.byTooltip('关闭')); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsNothing); +} + +void main() { + testWidgets('Header badges move overnight DDLs and update in place', ( + tester, + ) async { + final app = await _showHeader(tester, _deadlines()); + final headerElement = tester.element(find.byType(TimetableHeader)); + _expectCounts(tester, [0, 0, 3, 0, 0, 0, 0]); + + await app.settings.setDeadlineCutoff(_overnight); + await tester.pumpAndSettle(); + // Both 07:00 and the inclusive 08:00 endpoint become Tuesday 22:00. + // Wednesday noon is outside the window and stays on Wednesday. + _expectCounts(tester, [0, 2, 1, 0, 0, 0, 0]); + expect(tester.element(find.byType(TimetableHeader)), same(headerElement)); + + await app.settings.setDeadlineCutoff( + _overnight.copyWith(endMinute: 7 * 60), + ); + await tester.pumpAndSettle(); + _expectCounts(tester, [0, 1, 2, 0, 0, 0, 0]); + expect(tester.element(find.byType(TimetableHeader)), same(headerElement)); + + await app.settings.setDeadlineCutoff( + _overnight.copyWith(enabled: false), + ); + await tester.pumpAndSettle(); + _expectCounts(tester, [0, 0, 3, 0, 0, 0, 0]); + expect(tester.element(find.byType(TimetableHeader)), same(headerElement)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('Date badges open the same effective-day entries they count', ( + tester, + ) async { + final app = await _showHeader(tester, _deadlines()); + await app.settings.setDeadlineCutoff(_overnight); + await tester.pumpAndSettle(); + _expectCounts(tester, [0, 2, 1, 0, 0, 0, 0]); + + // Drawer membership for DDLs depends on completion, not the real clock + // used internally by showAgendaDay for its time/status captions. + await _openDay(tester, 1); + expect( + _dayRows(tester).map((row) => row.entry.id), + unorderedEquals(['morning-07', 'morning-08']), + ); + expect(find.text(_l10n.agendaCount(2)), findsOneWidget); + expect(find.text('作业 midday'), findsNothing); + await _closeDay(tester); + + await _openDay(tester, 2); + expect(_dayRows(tester).map((row) => row.entry.id), ['midday']); + expect(find.text(_l10n.agendaCount(1)), findsOneWidget); + expect(find.text('作业 morning-07'), findsNothing); + expect(find.text('作业 morning-08'), findsNothing); + await _closeDay(tester); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('Checking a drawer DDL decreases its badge, not merely passing due', ( + tester, + ) async { + final app = await _showHeader(tester, _deadlines()); + await app.settings.setDeadlineCutoff(_overnight); + await tester.pumpAndSettle(); + final projected = app.agenda.entries.firstWhere( + (entry) => entry.id == 'morning-07', + ); + expect(projected.dueAt, _tuesday.add(const Duration(hours: 22))); + expect(projected.overdue(_now), isTrue); + // An overdue but unfinished DDL must still contribute to the badge. + _expectCounts(tester, [0, 2, 1, 0, 0, 0, 0]); + + await _openDay(tester, 1); + final checkbox = find.descendant( + of: find.byKey(const ValueKey('morning-07')), + matching: find.byType(Checkbox), + ); + expect(checkbox, findsOneWidget); + await tester.tap(checkbox); + await tester.pumpAndSettle(); + expect( + app.agenda.entries.singleWhere((entry) => entry.id == 'morning-07').done, + isTrue, + ); + _expectCounts(tester, [0, 1, 1, 0, 0, 0, 0]); + expect(_dayRows(tester).map((row) => row.entry.id), ['morning-08']); + expect(find.text(_l10n.agendaCount(1)), findsOneWidget); + expect(find.text(_l10n.agendaCompletedCount(1)), findsOneWidget); + await _closeDay(tester); + _expectCounts(tester, [0, 1, 1, 0, 0, 0, 0]); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('Only timed DDL badges move; other entries and exclusions persist', ( + tester, + ) async { + final entries = [ + _deadline('ordinary-ddl', 7), + _deadline('open-practice', 7, startOnly: true), + _deadline('completed-ddl', 7, done: true), + _deadline('deleted-ddl', 8, deleted: true), + AgendaEntry( + id: 'event', + title: '普通日程', + kind: AgendaKind.event, + startsAt: _wednesday.add(const Duration(hours: 7)), + endsAt: _wednesday.add(const Duration(hours: 9)), + ), + AgendaEntry( + id: 'exam', + title: '考试', + kind: AgendaKind.exam, + startsAt: _wednesday.add(const Duration(hours: 8)), + endsAt: _wednesday.add(const Duration(hours: 10)), + ), + ]; + final app = await _showHeader(tester, entries); + _expectCounts(tester, [0, 0, 4, 0, 0, 0, 0]); + await app.settings.setDeadlineCutoff(_overnight); + await tester.pumpAndSettle(); + _expectCounts(tester, [0, 1, 3, 0, 0, 0, 0]); + await app.settings.setDeadlineCutoff(_overnight.copyWith(enabled: false)); + await tester.pumpAndSettle(); + _expectCounts(tester, [0, 0, 4, 0, 0, 0, 0]); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); +} diff --git a/test/deadline_cutoff_integration_test.dart b/test/deadline_cutoff_integration_test.dart index 6a5b849..b483d86 100644 --- a/test/deadline_cutoff_integration_test.dart +++ b/test/deadline_cutoff_integration_test.dart @@ -267,8 +267,10 @@ void main() { await tester.tap(find.text('打开详情')); await tester.pumpAndSettle(); final l10n = AppLocalizationsZh(); + expect(l10n.agendaAdjustedDeadline, '伪截止'); + expect(find.textContaining('伪截止时间', findRichText: true), findsNothing); expect( - find.text('22:00 ${l10n.agendaAdjustedDeadline}', findRichText: true), + find.text('22:00 伪截止', findRichText: true), findsOneWidget, ); final originalLabel = l10n.agendaCutoffOriginal( diff --git a/test/deadline_cutoff_settings_test.dart b/test/deadline_cutoff_settings_test.dart index 119f8e9..100f11b 100644 --- a/test/deadline_cutoff_settings_test.dart +++ b/test/deadline_cutoff_settings_test.dart @@ -44,12 +44,11 @@ class _DelayedWritePreferences extends MemoryPreferences { } class _FailOncePreferences extends MemoryPreferences { - bool failNext = true; + final _failedWrites = {}; @override Future setString(String key, String value) async { - if (key == _key && failNext) { - failNext = false; + if (key == _key && _failedWrites.add(key)) { throw Exception('simulated preference write failure'); } await super.setString(key, value); From c0eb179ee5a1b266d1876f8a5f4860f3429b322f Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Wed, 11 Nov 2026 02:53:34 +0800 Subject: [PATCH 03/24] feat(agenda): configure global deadline layout days --- lib/agenda/timeline_layout.dart | 19 +- lib/agenda/ui/agenda_editor.dart | 34 +-- lib/agenda/ui/agenda_timeline.dart | 17 +- lib/app/settings_controller.dart | 31 +++ lib/app/ui/agenda_layout_days_settings.dart | 139 ++++++++++ lib/app/ui/settings_screen.dart | 2 + lib/l10n/app_en.arb | 4 + lib/l10n/app_localizations.dart | 24 ++ lib/l10n/app_localizations_en.dart | 12 + lib/l10n/app_localizations_zh.dart | 12 + lib/l10n/app_zh.arb | 4 + test/agenda_adjusted_timeline_test.dart | 15 +- test/agenda_detail_test.dart | 43 +-- test/agenda_layout_days_test.dart | 285 ++++++++++++++++++++ test/agenda_start_only_test.dart | 12 +- test/agenda_timeline_test.dart | 10 +- 16 files changed, 573 insertions(+), 90 deletions(-) create mode 100644 lib/app/ui/agenda_layout_days_settings.dart create mode 100644 test/agenda_layout_days_test.dart diff --git a/lib/agenda/timeline_layout.dart b/lib/agenda/timeline_layout.dart index 77a7881..64ecb91 100644 --- a/lib/agenda/timeline_layout.dart +++ b/lib/agenda/timeline_layout.dart @@ -23,23 +23,20 @@ class AgendaTimelinePlacement { List layoutAgendaTimeline( Iterable entries, { required Duration minimumEventSpan, + int layoutDays = AgendaEntry.defaultTailDays, }) { - // An ordinary deadline hangs `tailDays` to the left of its due time. One - // that only records an opening time reserves the same span forwards from - // that opening time, so both reserve `tailDays` of lane space. + if (layoutDays < 1 || layoutDays > AgendaEntry.maxTailDays) { + throw RangeError.range(layoutDays, 1, AgendaEntry.maxTailDays, 'layoutDays'); + } + // All deadline types share a global reservation. Legacy per-entry tailDays + // remains stored, but no longer controls layout or painted width. DateTime reservationStart(AgendaEntry entry) => entry.isDeadline && !entry.startOnly - ? entry.dueAt.subtract( - Duration( - days: entry.hasDeadlineCutoff - ? AgendaEntry.defaultTailDays - : entry.tailDays, - ), - ) + ? entry.dueAt.subtract(Duration(days: layoutDays)) : entry.startsAt; DateTime reservationEnd(AgendaEntry entry) => entry.isDeadline ? entry.startOnly - ? entry.startsAt.add(Duration(days: entry.tailDays)) + ? entry.startsAt.add(Duration(days: layoutDays)) : entry.realDueAt : entry.endsAt!; final intervals = diff --git a/lib/agenda/ui/agenda_editor.dart b/lib/agenda/ui/agenda_editor.dart index d2539ce..4355e46 100644 --- a/lib/agenda/ui/agenda_editor.dart +++ b/lib/agenda/ui/agenda_editor.dart @@ -68,7 +68,9 @@ class _AgendaEditorState extends State<_AgendaEditor> { late final _title = TextEditingController(text: widget.entryTitle); late final _location = TextEditingController(text: widget.entry?.location); late final _notes = TextEditingController(text: widget.entryNotes); - late int _tailDays = widget.entry?.tailDays ?? AgendaEntry.defaultTailDays; + // Preserve legacy per-entry metadata without offering an ineffective override. + late final int _tailDays = + widget.entry?.tailDays ?? AgendaEntry.defaultTailDays; late AgendaKind _kind = widget.kind; late DateTime _start = widget.entry?.startsAt ?? widget.startsAt; late DateTime _end = @@ -335,36 +337,6 @@ class _AgendaEditorState extends State<_AgendaEditor> { ), if (_kind != AgendaKind.deadline) _when(l10n.end, _end, false), const SizedBox(height: 16), - if (_kind == AgendaKind.deadline) ...[ - Row( - children: [ - Expanded(child: Text(l10n.agendaTailDays)), - Text(l10n.agendaTailDaysValue(_tailDays)), - ], - ), - Semantics( - label: l10n.agendaTailDays, - child: Slider( - value: _tailDays.toDouble(), - min: 1, - max: AgendaEntry.maxTailDays.toDouble(), - divisions: AgendaEntry.maxTailDays - 1, - label: l10n.agendaTailDaysValue(_tailDays), - semanticFormatterCallback: (value) => - l10n.agendaTailDaysValue(value.round()), - onChanged: _saving - ? null - : (value) => setState(() => _tailDays = value.round()), - ), - ), - Text( - l10n.agendaTailDaysHint, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 12), - ], if (_kind != AgendaKind.deadline) ...[ TextFormField( controller: _location, diff --git a/lib/agenda/ui/agenda_timeline.dart b/lib/agenda/ui/agenda_timeline.dart index e7110c3..c5a3729 100644 --- a/lib/agenda/ui/agenda_timeline.dart +++ b/lib/agenda/ui/agenda_timeline.dart @@ -63,21 +63,26 @@ class _AgendaTimelineState extends State DateTime? _gestureAnchor; List? _cachedEntries; DeadlineCutoffWindow? _cachedCutoff; + int? _cachedLayoutDays; Duration? _cachedMinimumEventSpan; List _cachedPlacements = const []; final Map _nextStartById = {}; List _layout(Duration minimumEventSpan) { final cutoff = widget.controller.settings.deadlineCutoff.value; + final layoutDays = widget.controller.settings.agendaLayoutDays.value; if (!identical(_cachedEntries, widget.entries) || _cachedCutoff != cutoff || + _cachedLayoutDays != layoutDays || _cachedMinimumEventSpan != minimumEventSpan) { _cachedEntries = widget.entries; _cachedCutoff = cutoff; + _cachedLayoutDays = layoutDays; _cachedMinimumEventSpan = minimumEventSpan; _cachedPlacements = layoutAgendaTimeline( widget.entries.map((entry) => entry.withDeadlineCutoff(cutoff)), minimumEventSpan: minimumEventSpan, + layoutDays: layoutDays, ); _nextStartById.clear(); final nextByLane = {}; @@ -96,6 +101,7 @@ class _AgendaTimelineState extends State _center = calendarDate(widget.now).add(const Duration(hours: 12)); _lanes.addListener(_laneChanged); widget.controller.settings.deadlineCutoff.addListener(_laneChanged); + widget.controller.settings.agendaLayoutDays.addListener(_laneChanged); _horizontalFling = AnimationController.unbounded(vsync: this) ..addListener(_tickHorizontal); _verticalFling = AnimationController.unbounded(vsync: this) @@ -162,7 +168,9 @@ class _AgendaTimelineState extends State super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { oldWidget.controller.settings.deadlineCutoff.removeListener(_laneChanged); + oldWidget.controller.settings.agendaLayoutDays.removeListener(_laneChanged); widget.controller.settings.deadlineCutoff.addListener(_laneChanged); + widget.controller.settings.agendaLayoutDays.addListener(_laneChanged); _cachedEntries = null; } if (oldWidget.visible != widget.visible) { @@ -202,6 +210,7 @@ class _AgendaTimelineState extends State _verticalFling.dispose(); _lanes.removeListener(_laneChanged); widget.controller.settings.deadlineCutoff.removeListener(_laneChanged); + widget.controller.settings.agendaLayoutDays.removeListener(_laneChanged); _lanes.dispose(); super.dispose(); } @@ -289,12 +298,8 @@ class _AgendaTimelineState extends State final laneHeight = barHeight + 12; // Keep the tail a quarter baseline day inside its reservation. // Width is set by the MINIMUM zoom, never by the current zoom. - double deadlineWidth(AgendaEntry entry) => - cellWidth * - ((entry.hasDeadlineCutoff - ? AgendaEntry.defaultTailDays - : entry.tailDays) - - 0.25); + double deadlineWidth(AgendaEntry _) => + cellWidth * (widget.controller.settings.agendaLayoutDays.value - 0.25); Duration deadlineDuration(AgendaEntry entry) => Duration( microseconds: (deadlineWidth(entry) / pixelsPerHour * _microsecondsPerHour) diff --git a/lib/app/settings_controller.dart b/lib/app/settings_controller.dart index 6817a20..c668bfc 100644 --- a/lib/app/settings_controller.dart +++ b/lib/app/settings_controller.dart @@ -47,6 +47,7 @@ class AppSettingsController extends ChangeNotifier { final agendaViewMode = ValueNotifier(AgendaViewMode.timeline); final timetableHeightMode = ValueNotifier(TimetableHeightMode.normal); final deadlineCutoff = ValueNotifier(const DeadlineCutoffWindow()); + final agendaLayoutDays = ValueNotifier(4); Future? _paletteSettingsLoad; SystemPalette? _systemPalette; @@ -56,6 +57,7 @@ class AppSettingsController extends ChangeNotifier { bool _agendaViewChosen = false; bool _timetableHeightChosen = false; bool _deadlineCutoffChosen = false; + bool _agendaLayoutDaysChosen = false; bool _developerMode = false; bool _widgetUseSystemPalette = false; bool _globalUseSystemPalette = false; @@ -161,6 +163,19 @@ class AppSettingsController extends ChangeNotifier { } catch (failure, stack) { AppDiagnostics.instance.report('读取 DDL 伪提前设置', failure, stack); } + try { + final saved = await _preferences.getString('agendaLayoutDays'); + if (!_agendaLayoutDaysChosen && !_disposed && saved != null) { + final days = int.tryParse(saved); + if (days == null || days < 1 || days > 30) { + throw const FormatException('DDL 排版宽度必须为 1–30 天'); + } + agendaLayoutDays.value = days; + _notify(); + } + } catch (failure, stack) { + AppDiagnostics.instance.report('读取 DDL 排版宽度', failure, stack); + } } Future setTheme(ThemeMode value) { @@ -372,6 +387,21 @@ class AppSettingsController extends ChangeNotifier { ); } + Future setAgendaLayoutDays(int value) { + if (_disposed) return Future.value(); + if (value < 1 || value > 30) { + throw RangeError.range(value, 1, 30, 'agendaLayoutDays'); + } + _agendaLayoutDaysChosen = true; + agendaLayoutDays.value = value; + _notify(); + return _writePreference( + 'agendaLayoutDays', + value.toString(), + '保存 DDL 排版宽度', + ); + } + Future _writePreference(String key, String value, String operation) { final previous = _preferenceWrites[key] ?? Future.value(); final write = previous.then((_) async { @@ -397,6 +427,7 @@ class AppSettingsController extends ChangeNotifier { agendaViewMode.dispose(); timetableHeightMode.dispose(); deadlineCutoff.dispose(); + agendaLayoutDays.dispose(); super.dispose(); } } diff --git a/lib/app/ui/agenda_layout_days_settings.dart b/lib/app/ui/agenda_layout_days_settings.dart new file mode 100644 index 0000000..0ed313b --- /dev/null +++ b/lib/app/ui/agenda_layout_days_settings.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../l10n/l10n.dart'; +import '../../ui/app_page.dart'; +import '../settings_controller.dart'; + +class AgendaLayoutDaysSettingsTile extends StatelessWidget { + const AgendaLayoutDaysSettingsTile({super.key, required this.settings}); + + final AppSettingsController settings; + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: settings.agendaLayoutDays, + builder: (context, days, _) => ListTile( + key: const ValueKey('agenda-layout-days-settings'), + contentPadding: const EdgeInsets.symmetric(vertical: 2), + leading: const Icon(Icons.view_week_outlined), + title: Text(context.l10n.settingsAgendaLayoutDays), + subtitle: Text(context.l10n.agendaTailDaysValue(days)), + trailing: const Icon(Icons.chevron_right_rounded, size: 20), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AgendaLayoutDaysScreen(settings: settings), + ), + ), + ), + ); +} + +class AgendaLayoutDaysScreen extends StatefulWidget { + const AgendaLayoutDaysScreen({super.key, required this.settings}); + + final AppSettingsController settings; + + @override + State createState() => _AgendaLayoutDaysScreenState(); +} + +class _AgendaLayoutDaysScreenState extends State { + late final _days = TextEditingController( + text: widget.settings.agendaLayoutDays.value.toString(), + ); + bool _invalid = false; + + @override + void initState() { + super.initState(); + widget.settings.agendaLayoutDays.addListener(_syncValue); + } + + void _syncValue() { + if (!mounted) return; + setState(() { + _days.text = widget.settings.agendaLayoutDays.value.toString(); + _invalid = false; + }); + } + + Future _saveText(String text) async { + final value = int.tryParse(text); + if (value == null || value < 1 || value > 30) { + setState(() => _invalid = true); + return; + } + await widget.settings.setAgendaLayoutDays(value); + _syncValue(); + } + + @override + void didUpdateWidget(covariant AgendaLayoutDaysScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.settings != widget.settings) { + oldWidget.settings.agendaLayoutDays.removeListener(_syncValue); + widget.settings.agendaLayoutDays.addListener(_syncValue); + _syncValue(); + } + } + + @override + void dispose() { + widget.settings.agendaLayoutDays.removeListener(_syncValue); + _days.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AppRoutePage( + title: context.l10n.settingsAgendaLayoutDays, + child: ValueListenableBuilder( + valueListenable: widget.settings.agendaLayoutDays, + builder: (context, days, _) { + final l10n = context.l10n; + return ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + children: [ + Text(l10n.agendaLayoutDaysDescription), + const SizedBox(height: 20), + TextFormField( + key: const ValueKey('agenda-layout-days-input'), + controller: _days, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + labelText: l10n.settingsAgendaLayoutDays, + suffixText: l10n.agendaLayoutDaysUnit, + helperText: l10n.agendaLayoutDaysRange, + errorText: _invalid ? l10n.agendaLayoutDaysRange : null, + ), + onFieldSubmitted: _saveText, + ), + const SizedBox(height: 12), + Semantics( + label: l10n.settingsAgendaLayoutDays, + child: Slider( + key: const ValueKey('agenda-layout-days-slider'), + min: 1, + max: 30, + divisions: 29, + value: days.toDouble(), + label: l10n.agendaTailDaysValue(days), + semanticFormatterCallback: (value) => + l10n.agendaTailDaysValue(value.round()), + onChanged: (value) => + widget.settings.setAgendaLayoutDays(value.round()), + ), + ), + Text( + l10n.agendaTailDaysValue(days), + textAlign: TextAlign.center, + ), + ], + ); + }, + ), + ); +} diff --git a/lib/app/ui/settings_screen.dart b/lib/app/ui/settings_screen.dart index 34ccec5..b9234f5 100644 --- a/lib/app/ui/settings_screen.dart +++ b/lib/app/ui/settings_screen.dart @@ -18,6 +18,7 @@ import '../../ui/section_label.dart'; import '../controller.dart'; import '../settings_controller.dart'; import 'about_dialog.dart'; +import 'agenda_layout_days_settings.dart'; import 'appearance_screen.dart'; import 'deadline_cutoff_settings.dart'; import 'logs_screen.dart'; @@ -212,6 +213,7 @@ class SettingsScreen extends StatelessWidget { ), ]), section(l10n.settingsLocalAgenda, [ + AgendaLayoutDaysSettingsTile(settings: controller.settings), DeadlineCutoffSettingsTile(settings: controller.settings), ListTile( contentPadding: tilePadding, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 129de58..8a703ad 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1780,6 +1780,10 @@ } } }, + "settingsAgendaLayoutDays": "DDL layout width", + "agendaLayoutDaysDescription": "Use one layout width for deadlines, start-only items, and the left side of adjusted deadlines, overriding old per-item tails. Painted tails keep a fixed screen width.", + "agendaLayoutDaysUnit": "days", + "agendaLayoutDaysRange": "Enter a whole number from 1 to 30", "releaseVersion": "{channel} {version}", "@releaseVersion": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c78c8e5..500fd1a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4320,6 +4320,30 @@ abstract class AppLocalizations { /// **'Adjusted deadline: {time}'** String agendaCutoffAdjusted(String time); + /// No description provided for @settingsAgendaLayoutDays. + /// + /// In en, this message translates to: + /// **'DDL layout width'** + String get settingsAgendaLayoutDays; + + /// No description provided for @agendaLayoutDaysDescription. + /// + /// In en, this message translates to: + /// **'Use one layout width for deadlines, start-only items, and the left side of adjusted deadlines, overriding old per-item tails. Painted tails keep a fixed screen width.'** + String get agendaLayoutDaysDescription; + + /// No description provided for @agendaLayoutDaysUnit. + /// + /// In en, this message translates to: + /// **'days'** + String get agendaLayoutDaysUnit; + + /// No description provided for @agendaLayoutDaysRange. + /// + /// In en, this message translates to: + /// **'Enter a whole number from 1 to 30'** + String get agendaLayoutDaysRange; + /// No description provided for @releaseVersion. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6640b24..e46ebe0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2512,6 +2512,18 @@ class AppLocalizationsEn extends AppLocalizations { return 'Adjusted deadline: $time'; } + @override + String get settingsAgendaLayoutDays => 'DDL layout width'; + + @override + String get agendaLayoutDaysDescription => 'Use one layout width for deadlines, start-only items, and the left side of adjusted deadlines, overriding old per-item tails. Painted tails keep a fixed screen width.'; + + @override + String get agendaLayoutDaysUnit => 'days'; + + @override + String get agendaLayoutDaysRange => 'Enter a whole number from 1 to 30'; + @override String releaseVersion(String channel, String version) { return '$channel $version'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 70d6086..8cca18d 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2398,6 +2398,18 @@ class AppLocalizationsZh extends AppLocalizations { return '伪截止:$time'; } + @override + String get settingsAgendaLayoutDays => 'DDL 排版宽度'; + + @override + String get agendaLayoutDaysDescription => '统一控制 DDL、仅开始时间项目及伪提前 DDL 左侧信息区的排版宽度,覆盖原单条拖尾设置;渲染仍保持屏幕定宽。'; + + @override + String get agendaLayoutDaysUnit => '天'; + + @override + String get agendaLayoutDaysRange => '请输入 1–30 的整数'; + @override String releaseVersion(String channel, String version) { return '$channel $version'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 3fc1e63..fd5c3b0 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -700,5 +700,9 @@ "agendaRealDeadline": "真实截止时间", "agendaCutoffOriginal": "真实截止:{time}", "agendaCutoffAdjusted": "伪截止:{time}", + "settingsAgendaLayoutDays": "DDL 排版宽度", + "agendaLayoutDaysDescription": "统一控制 DDL、仅开始时间项目及伪提前 DDL 左侧信息区的排版宽度,覆盖原单条拖尾设置;渲染仍保持屏幕定宽。", + "agendaLayoutDaysUnit": "天", + "agendaLayoutDaysRange": "请输入 1–30 的整数", "releaseVersion": "{channel} {version}" } diff --git a/test/agenda_adjusted_timeline_test.dart b/test/agenda_adjusted_timeline_test.dart index 80de197..f77e8d8 100644 --- a/test/agenda_adjusted_timeline_test.dart +++ b/test/agenda_adjusted_timeline_test.dart @@ -80,7 +80,7 @@ Future _pixels(WidgetTester tester, GlobalKey key) async => key.currentContext!.findRenderObject()! as RenderRepaintBoundary; final image = await boundary.toImage(pixelRatio: 1); final data = await image.toByteData( - format: ui.ImageByteFormat.rawStraightRgba, + format: ui.ImageByteFormat.rawRgba, ); image.dispose(); return data!; @@ -116,7 +116,7 @@ void main() { expect(placements.map((p) => p.lane).toSet(), hasLength(2)); }); - test('Unadjusted and start-only reservations retain their custom tails', () { + test('Unadjusted and start-only reservations use the global width', () { for (final startOnly in [false, true]) { final entry = _deadline( tailDays: 14, @@ -126,6 +126,7 @@ void main() { final placement = layoutAgendaTimeline( [entry], minimumEventSpan: const Duration(minutes: 1), + layoutDays: 14, ).single; expect( placement.start, @@ -350,9 +351,13 @@ void main() { // x+y remains constant when moving along a 45-degree band. expect(channel(6, 8, 3), channel(10, 4, 3)); for (final x in [10, 26]) { - expect(channel(x, 4, 0), closeTo(0x33, 3)); - expect(channel(x, 4, 1), closeTo(0x66, 3)); - expect(channel(x, 4, 2), closeTo(0x99, 3)); + // rawRgba is premultiplied: verify the palette against the measured + // alpha instead of unpremultiplying low-alpha bytes and amplifying + // Skia's integer rounding (the #455 baseline failure). + final paintedAlpha = channel(x, 4, 3) / 255; + expect(channel(x, 4, 0), closeTo(0x33 * paintedAlpha, 2)); + expect(channel(x, 4, 1), closeTo(0x66 * paintedAlpha, 2)); + expect(channel(x, 4, 2), closeTo(0x99 * paintedAlpha, 2)); } await tester.pumpWidget(const SizedBox()); }); diff --git a/test/agenda_detail_test.dart b/test/agenda_detail_test.dart index c241b31..1010925 100644 --- a/test/agenda_detail_test.dart +++ b/test/agenda_detail_test.dart @@ -19,13 +19,18 @@ void main() { final url = Uri.https('lexue.bit.edu.cn', '/mod/assign/view.php', { 'id': '42', }); - AgendaEntry deadline({bool detailed = false, bool allDay = false}) => + AgendaEntry deadline({ + bool detailed = false, + bool allDay = false, + int tailDays = 4, + }) => AgendaEntry( id: 'assignment', title: detailed ? '第三章作业:线性表与链表的实现、测试和复杂度分析' : '1', kind: AgendaKind.deadline, startsAt: DateTime.utc(2020, 10, 27, 1, 11), allDay: allDay, + tailDays: tailDays, course: detailed ? '2026–2027 秋季学期 · 数据结构中文班' : '', source: detailed ? DeadlineSource.lexue : null, url: detailed ? url : null, @@ -210,10 +215,10 @@ void main() { expect(find.byType(SheetLayout), findsOneWidget); }); - testWidgets('Edit opens the existing editor and saves the same entry', ( + testWidgets('Edit preserves the entry and legacy width without a per-item slider', ( tester, ) async { - final controller = await open(tester, deadline()); + final controller = await open(tester, deadline(tailDays: 14)); await tester.tap(find.widgetWithText(TextButton, '编辑')); await tester.pumpAndSettle(); expect(find.text('编辑DDL'), findsOneWidget); @@ -221,36 +226,15 @@ void main() { (widget) => widget is TextField && widget.decoration?.labelText == '名称', ); expect(tester.widget(name).controller!.text, '1'); - final tailDays = find.byType(Slider); - expect(tester.widget(tailDays).value, 4); - expect(tester.widget(tailDays).divisions, 29); - expect(find.text('4 天'), findsOneWidget); - await tester.ensureVisible(tailDays); - final track = tester.getRect(tailDays); - await tester.tapAt(Offset(track.right - 1, track.center.dy)); - await tester.pumpAndSettle(); - expect(tester.widget(tailDays).value, 30); - expect(find.text('30 天'), findsOneWidget); - await tester.tapAt(Offset(track.left + 1, track.center.dy)); - await tester.pumpAndSettle(); - expect(tester.widget(tailDays).value, 1); - expect(find.text('1 天'), findsOneWidget); - await tester.dragFrom( - Offset(track.left + 24, track.center.dy), - Offset(track.width / 2 - 24, 0), - ); - await tester.pumpAndSettle(); - final selectedDays = tester.widget(tailDays).value; - expect(selectedDays, inExclusiveRange(1, 30)); - expect(selectedDays, selectedDays.roundToDouble()); - expect(find.text('${selectedDays.round()} 天'), findsOneWidget); - expect(controller.agenda.entries.single.tailDays, 4); + expect(find.byType(Slider), findsNothing); + expect(find.text('拖尾长度(天)'), findsNothing); + expect(controller.agenda.entries.single.tailDays, 14); await tester.enterText(name, '第二章习题'); await tester.tap(find.widgetWithText(FilledButton, '保存')); await tester.pumpAndSettle(); expect(controller.agenda.entries.single.id, 'assignment'); expect(controller.agenda.entries.single.title, '第二章习题'); - expect(controller.agenda.entries.single.tailDays, selectedDays.round()); + expect(controller.agenda.entries.single.tailDays, 14); expect(find.byType(SheetLayout), findsNothing); unawaited( showAgendaEntry( @@ -262,7 +246,8 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(TextButton, '编辑')); await tester.pumpAndSettle(); - expect(tester.widget(tailDays).value, selectedDays); + expect(find.byType(Slider), findsNothing); + expect(controller.agenda.entries.single.tailDays, 14); expect(tester.takeException(), isNull); }); diff --git a/test/agenda_layout_days_test.dart b/test/agenda_layout_days_test.dart new file mode 100644 index 0000000..e3672cb --- /dev/null +++ b/test/agenda_layout_days_test.dart @@ -0,0 +1,285 @@ +import 'dart:async'; + +import 'package:bitree/agenda/model.dart'; +import 'package:bitree/agenda/timeline_layout.dart'; +import 'package:bitree/agenda/ui/agenda_timeline.dart'; +import 'package:bitree/app/settings_controller.dart'; +import 'package:bitree/app/ui/agenda_layout_days_settings.dart'; +import 'package:bitree/l10n/l10n.dart'; +import 'package:bitree/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/test_app.dart'; + +const _key = 'agendaLayoutDays'; +final _date = DateTime.utc(2026, 11, 17, 12); + +class _DelayedReadPreferences extends MemoryPreferences { + final started = Completer(); + final result = Completer(); + + @override + Future getString(String key) { + if (key != _key) return super.getString(key); + if (!started.isCompleted) started.complete(); + return result.future; + } +} + +class _DelayedWritePreferences extends MemoryPreferences { + final started = Completer(); + final release = Completer(); + final writes = []; + + @override + Future setString(String key, String value) async { + if (key != _key) return super.setString(key, value); + writes.add(value); + if (writes.length == 1) { + started.complete(); + await release.future; + } + await super.setString(key, value); + } +} + +AppSettingsController _settings(MemoryPreferences preferences) => + AppSettingsController(preferences, coursePaletteLoader: () async => {}); + +AgendaEntry _entry(String id, { + bool startOnly = false, + DeadlineCutoffWindow cutoff = const DeadlineCutoffWindow(), +}) => AgendaEntry( + id: id, + title: id, + kind: AgendaKind.deadline, + startsAt: _date, + startOnly: startOnly, + tailDays: id == 'plain' ? 1 : 30, + deadlineCutoffWindow: cutoff, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Layout days default to four without writing preferences', () async { + final preferences = MemoryPreferences(); + final settings = _settings(preferences); + addTearDown(settings.dispose); + await settings.initialize(); + expect(settings.agendaLayoutDays.value, 4); + expect(preferences.value(_key), isNull); + }); + + test('Layout days restore, publish immediately and survive restart', () async { + final preferences = MemoryPreferences({_key: '7'}); + final first = _settings(preferences); + addTearDown(first.dispose); + await first.initialize(); + expect(first.agendaLayoutDays.value, 7); + final writing = first.setAgendaLayoutDays(12); + expect(first.agendaLayoutDays.value, 12); + await writing; + expect(preferences.value(_key), '12'); + final restarted = _settings(preferences); + addTearDown(restarted.dispose); + await restarted.initialize(); + expect(restarted.agendaLayoutDays.value, 12); + }); + + for (final invalid in ['', '0', '31', '-2', '2.5', 'unknown']) { + test('Invalid saved width falls back to four: $invalid', () async { + final settings = _settings(MemoryPreferences({_key: invalid})); + addTearDown(settings.dispose); + await settings.initialize(); + expect(settings.agendaLayoutDays.value, 4); + }); + } + + test('Invalid setter values cannot change the selected width', () async { + final settings = _settings(MemoryPreferences()); + addTearDown(settings.dispose); + expect(() => settings.setAgendaLayoutDays(0), throwsRangeError); + expect(() => settings.setAgendaLayoutDays(31), throwsRangeError); + expect(settings.agendaLayoutDays.value, 4); + }); + + test('Rapid width changes persist in order with the latest last', () async { + final preferences = _DelayedWritePreferences(); + final settings = _settings(preferences); + addTearDown(settings.dispose); + await settings.initialize(); + final first = settings.setAgendaLayoutDays(1); + await preferences.started.future; + final middle = settings.setAgendaLayoutDays(30); + final last = settings.setAgendaLayoutDays(8); + expect(settings.agendaLayoutDays.value, 8); + preferences.release.complete(); + await Future.wait([first, middle, last]); + expect(preferences.writes, ['1', '30', '8']); + expect(preferences.value(_key), '8'); + }); + + test('A late read does not replace a newer user width', () async { + final preferences = _DelayedReadPreferences(); + final settings = _settings(preferences); + addTearDown(settings.dispose); + final loading = settings.initialize(); + await preferences.started.future; + await settings.setAgendaLayoutDays(9); + preferences.result.complete('14'); + await loading; + expect(settings.agendaLayoutDays.value, 9); + expect(preferences.value(_key), '9'); + }); + + test('A pending read and setter cannot publish after disposal', () async { + final preferences = _DelayedReadPreferences(); + final settings = _settings(preferences); + final loading = settings.initialize(); + await preferences.started.future; + settings.dispose(); + preferences.result.complete('14'); + await expectLater(loading, completes); + await expectLater(settings.setAgendaLayoutDays(8), completes); + expect(preferences.value(_key), isNull); + }); + + for (final days in [1, 4, 30]) { + test('All DDL reservations use the global $days days, not legacy values', () { + const cutoff = DeadlineCutoffWindow( + enabled: true, + startMinute: 10 * 60, + endMinute: 18 * 60, + ); + final entries = [ + _entry('plain'), + _entry('open', startOnly: true), + _entry('adjusted', cutoff: cutoff), + ]; + final placements = layoutAgendaTimeline( + entries, + minimumEventSpan: const Duration(minutes: 1), + layoutDays: days, + ); + for (final placement in placements) { + final entry = placement.entry; + if (entry.startOnly) { + expect(placement.start, entry.startsAt); + expect(placement.end, entry.startsAt.add(Duration(days: days))); + } else { + expect(placement.start, entry.dueAt.subtract(Duration(days: days))); + expect(placement.end, entry.realDueAt); + } + } + expect(entries.map((e) => e.tailDays), [1, 30, 30]); + expect(entries.every((e) => e.startsAt == _date), isTrue); + expect(entries.first.toJson()['tailDays'], 1); + expect(entries.last.toJson()['tailDays'], 30); + }); + } + + testWidgets('One live width controls plain, open and adjusted DDL bodies', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final entries = [_entry('plain'), _entry('open', startOnly: true), _entry('adjusted')]; + final app = TestAppController(entries: entries); + await app.initialize(); + // Plain and adjusted use different clock times so only the latter matches. + final adjusted = AgendaEntry( + id: 'adjusted', title: 'adjusted', kind: AgendaKind.deadline, + startsAt: _date.add(const Duration(hours: 2)), tailDays: 30, + ); + final shown = [entries[0], entries[1], adjusted]; + await app.settings.setDeadlineCutoff(const DeadlineCutoffWindow( + enabled: true, startMinute: 13 * 60, endMinute: 15 * 60, + )); + await tester.pumpWidget(MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold(body: AgendaTimeline( + controller: app, entries: shown, now: _date, + )), + )); + await tester.pumpAndSettle(); + final state = tester.state(find.byType(AgendaTimeline)); + for (final days in [4, 8]) { + await app.settings.setAgendaLayoutDays(days); + await tester.pumpAndSettle(); + for (final entry in shown) { + final bar = find.byKey(ValueKey(('agenda-timeline-bar', entry.id))); + expect(bar, findsOneWidget); + expect(tester.getSize(bar).width, closeTo(48 * (days - .25), .001)); + } + expect(tester.state(find.byType(AgendaTimeline)), same(state)); + } + tester.widget(find.byType(Slider)).onChanged!(24); + await tester.pumpAndSettle(); + for (final entry in shown) { + final bar = find.byKey(ValueKey(('agenda-timeline-bar', entry.id))); + expect(tester.getSize(bar).width, closeTo(48 * (8 - .25), .001)); + } + expect(entries.map((e) => e.tailDays), [1, 30, 30]); + expect(adjusted.tailDays, 30); + expect(adjusted.startsAt, _date.add(const Duration(hours: 2))); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + for (final locale in [const Locale('zh'), const Locale('en')]) { + testWidgets('Width settings work on a narrow large-text screen in $locale', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final preferences = MemoryPreferences(); + final settings = _settings(preferences); + addTearDown(settings.dispose); + await settings.initialize(); + await tester.pumpWidget(MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: bitreeTheme(Brightness.light), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1.8)), + child: child!, + ), + home: AgendaLayoutDaysScreen(settings: settings), + )); + await tester.pumpAndSettle(); + final field = find.byKey(const ValueKey('agenda-layout-days-input')); + await tester.ensureVisible(field); + await tester.enterText(field, '7'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(settings.agendaLayoutDays.value, 7); + expect(preferences.value(_key), '7'); + await tester.enterText(field, '31'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(settings.agendaLayoutDays.value, 7); + final context = tester.element(find.byType(AgendaLayoutDaysScreen)); + expect(find.text(context.l10n.agendaLayoutDaysRange), findsWidgets); + await settings.setAgendaLayoutDays(12); + await tester.pumpAndSettle(); + final textField = tester.widget(field); + expect(textField.controller!.text, '12'); + final slider = find.byKey(const ValueKey('agenda-layout-days-slider')); + await tester.ensureVisible(slider); + await tester.pumpAndSettle(); + tester.widget(slider).onChanged!(30); + await tester.pumpAndSettle(); + expect(settings.agendaLayoutDays.value, 30); + expect(preferences.value(_key), '30'); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + } +} diff --git a/test/agenda_start_only_test.dart b/test/agenda_start_only_test.dart index 4b0d480..f77da54 100644 --- a/test/agenda_start_only_test.dart +++ b/test/agenda_start_only_test.dart @@ -113,6 +113,7 @@ void main() { final placement = layoutAgendaTimeline( [_openEnded(tailDays: 6)], minimumEventSpan: const Duration(hours: 1), + layoutDays: 6, ).single; expect(placement.start, _opens); @@ -124,6 +125,7 @@ void main() { final placement = layoutAgendaTimeline( [_due(tailDays: 6)], minimumEventSpan: const Duration(hours: 1), + layoutDays: 6, ).single; expect(placement.start, _opens.subtract(const Duration(days: 6))); @@ -259,14 +261,16 @@ void main() { group('timeline', () { Future showTimeline( WidgetTester tester, - List entries, - ) async { + List entries, { + int layoutDays = 4, + }) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final app = TestAppController(entries: entries); await app.initialize(); + await app.settings.setAgendaLayoutDays(layoutDays); await tester.pumpWidget( MaterialApp( theme: bitreeTheme(Brightness.light), @@ -291,7 +295,7 @@ void main() { ) async { final due = _due(tailDays: 6); final open = _openEnded(tailDays: 6); - await showTimeline(tester, [due, open]); + await showTimeline(tester, [due, open], layoutDays: 6); final dueRect = bar(tester, due); final openRect = bar(tester, open); @@ -307,7 +311,7 @@ void main() { testWidgets('The card stays painted as the view zooms in', (tester) async { final open = _openEnded(tailDays: 12); - await showTimeline(tester, [open]); + await showTimeline(tester, [open], layoutDays: 12); final before = bar(tester, open); expect(before.width, closeTo(48 * (12 - .25), .001)); tester.widget(find.byType(Slider)).onChanged!(24); diff --git a/test/agenda_timeline_test.dart b/test/agenda_timeline_test.dart index b51bd6e..cac96cf 100644 --- a/test/agenda_timeline_test.dart +++ b/test/agenda_timeline_test.dart @@ -53,6 +53,7 @@ Future _show( List entries, { double scale = 1, Brightness brightness = Brightness.light, + int layoutDays = 4, }) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; @@ -60,6 +61,7 @@ Future _show( addTearDown(tester.view.resetDevicePixelRatio); final app = TestAppController(entries: entries); await app.initialize(); + await app.settings.setAgendaLayoutDays(layoutDays); await tester.pumpWidget( MaterialApp( theme: bitreeTheme(brightness), @@ -141,7 +143,7 @@ void main() { }); for (final days in [1, 7, 14]) { - test('DDL lane reservations follow $days tail days', () { + test('DDL lane reservations follow $days global layout days', () { final ddl = AgendaEntry( id: 'custom', title: '作业', @@ -160,7 +162,7 @@ void main() { final placements = layoutAgendaTimeline([ ddl, overlapping, - ], minimumEventSpan: const Duration(minutes: 1)); + ], minimumEventSpan: const Duration(minutes: 1), layoutDays: days); final reservation = placements.singleWhere((p) => p.entry == ddl); expect(reservation.start, ddl.dueAt.subtract(Duration(days: days))); expect(reservation.end, ddl.dueAt); @@ -177,7 +179,7 @@ void main() { startsAt: _date.add(const Duration(hours: 13)), tailDays: days, ); - await _show(tester, [ddl]); + await _show(tester, [ddl], layoutDays: days); final before = tester.getRect(_paintedBar(ddl)); expect(before.width, closeTo(48 * (days - .25), .001)); tester.widget(find.byType(Slider)).onChanged!(24); @@ -200,7 +202,7 @@ void main() { startsAt: _date.add(const Duration(days: 7)), tailDays: 14, ); - await _show(tester, [ddl]); + await _show(tester, [ddl], layoutDays: 14); final viewport = tester.getRect(find.byType(AgendaTimeline)); final tail = tester.getRect(_paintedBar(ddl)); expect(tail.right, greaterThan(viewport.right)); From 45382d444f43c9c2b1b12a18a80fcc8bc1e92cdb Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Wed, 11 Nov 2026 02:54:46 +0800 Subject: [PATCH 04/24] feat(settings): keep the native deadline clock resident --- lib/app/ui/deadline_cutoff_settings.dart | 401 +++++++++++++++++++---- test/deadline_cutoff_settings_test.dart | 254 ++++++++++++-- 2 files changed, 569 insertions(+), 86 deletions(-) diff --git a/lib/app/ui/deadline_cutoff_settings.dart b/lib/app/ui/deadline_cutoff_settings.dart index 773d018..df30f4c 100644 --- a/lib/app/ui/deadline_cutoff_settings.dart +++ b/lib/app/ui/deadline_cutoff_settings.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../../agenda/deadline_cutoff.dart'; @@ -11,6 +13,10 @@ String _clock(BuildContext context, int minute) => alwaysUse24HourFormat: true, ); +String _inputClock(int minute) => + '${(minute ~/ 60).toString().padLeft(2, '0')}:' + '${(minute % 60).toString().padLeft(2, '0')}'; + String _windowSummary(BuildContext context, DeadlineCutoffWindow window) { final l10n = context.l10n; final start = _clock(context, window.startMinute); @@ -51,85 +57,354 @@ class DeadlineCutoffSettingsTile extends StatelessWidget { ); } -class DeadlineCutoffScreen extends StatelessWidget { +class DeadlineCutoffScreen extends StatefulWidget { const DeadlineCutoffScreen({super.key, required this.settings}); final AppSettingsController settings; - Future _selectTime(BuildContext context, {required bool start}) async { - final current = settings.deadlineCutoff.value; - final minute = start ? current.startMinute : current.endMinute; - final selected = await showTimePicker( - context: context, - initialTime: TimeOfDay(hour: minute ~/ 60, minute: minute % 60), - initialEntryMode: TimePickerEntryMode.input, - helpText: start - ? context.l10n.deadlineCutoffStart - : context.l10n.deadlineCutoffEnd, + @override + State createState() => _DeadlineCutoffScreenState(); +} + +class _DeadlineCutoffScreenState extends State { + late DeadlineCutoffWindow _window; + late final TextEditingController _start; + late final TextEditingController _end; + final _startFocus = FocusNode(); + final _endFocus = FocusNode(); + bool _editingStart = true; + bool _startInvalid = false; + bool _endInvalid = false; + + @override + void initState() { + super.initState(); + _window = widget.settings.deadlineCutoff.value; + _start = TextEditingController(text: _inputClock(_window.startMinute)); + _end = TextEditingController(text: _inputClock(_window.endMinute)); + _startFocus.addListener(_startFocusChanged); + _endFocus.addListener(_endFocusChanged); + widget.settings.deadlineCutoff.addListener(_settingsChanged); + } + + @override + void didUpdateWidget(covariant DeadlineCutoffScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.settings != widget.settings) { + oldWidget.settings.deadlineCutoff.removeListener(_settingsChanged); + widget.settings.deadlineCutoff.addListener(_settingsChanged); + _syncInputs(widget.settings.deadlineCutoff.value); + } + } + + void _syncInputs(DeadlineCutoffWindow next) { + // A switch or opposite endpoint change must not destroy partially typed + // input. Only a changed persisted endpoint replaces its own text draft. + if (next.startMinute != _window.startMinute) { + _start.text = _inputClock(next.startMinute); + _startInvalid = false; + } + if (next.endMinute != _window.endMinute) { + _end.text = _inputClock(next.endMinute); + _endInvalid = false; + } + _window = next; + } + + void _settingsChanged() { + if (!mounted) return; + setState(() => _syncInputs(widget.settings.deadlineCutoff.value)); + } + + void _startFocusChanged() => _focusChanged(start: true); + void _endFocusChanged() => _focusChanged(start: false); + + void _focusChanged({required bool start}) { + final focus = start ? _startFocus : _endFocus; + if (!mounted) return; + if (focus.hasFocus) { + if (_editingStart != start) setState(() => _editingStart = start); + } else { + _commitInput(start: start); + } + } + + void _commitInput({required bool start}) { + if (!mounted) return; + final controller = start ? _start : _end; + final match = RegExp(r'^\s*(\d{1,2}):(\d{2})\s*$') + .firstMatch(controller.text); + final hour = match == null ? null : int.tryParse(match[1]!); + final minute = match == null ? null : int.tryParse(match[2]!); + final valid = hour != null && hour < 24 && minute != null && minute < 60; + setState(() { + if (start) { + _startInvalid = !valid; + } else { + _endInvalid = !valid; + } + }); + if (hour == null || hour >= 24 || minute == null || minute >= 60) return; + final value = hour * 60 + minute; + controller.text = _inputClock(value); + final latest = widget.settings.deadlineCutoff.value; + if (value == (start ? latest.startMinute : latest.endMinute)) return; + unawaited(widget.settings.setDeadlineCutoff( + start + ? latest.copyWith(startMinute: value) + : latest.copyWith(endMinute: value), + )); + } + + void _resetInput(int value, {required bool start}) { + setState(() { + if (start) { + _start.text = _inputClock(value); + _startInvalid = false; + } else { + _end.text = _inputClock(value); + _endInvalid = false; + } + }); + } + + void _cancelTime({required bool start}) { + final latest = widget.settings.deadlineCutoff.value; + _resetInput( + start ? latest.startMinute : latest.endMinute, + start: start, ); - if (selected == null || !context.mounted) return; - final selectedMinute = selected.hour * 60 + selected.minute; - // A picker may stay open while another setting changes. Retain the newest - // enabled flag and opposite endpoint rather than writing the old snapshot. - final latest = settings.deadlineCutoff.value; - await settings.setDeadlineCutoff( + } + + Future _selectTime(TimeOfDay selected, {required bool start}) { + final value = selected.hour * 60 + selected.minute; + _resetInput(value, start: start); + // The picker can remain mounted while enabled or the opposite endpoint + // changes. Read the latest configuration, never its opening snapshot. + final latest = widget.settings.deadlineCutoff.value; + return widget.settings.setDeadlineCutoff( start - ? latest.copyWith(startMinute: selectedMinute) - : latest.copyWith(endMinute: selectedMinute), + ? latest.copyWith(startMinute: value) + : latest.copyWith(endMinute: value), ); } + void _selectEndpoint(bool start) { + FocusScope.of(context).unfocus(); + setState(() => _editingStart = start); + } + + @override + void dispose() { + widget.settings.deadlineCutoff.removeListener(_settingsChanged); + _startFocus.removeListener(_startFocusChanged); + _endFocus.removeListener(_endFocusChanged); + _startFocus.dispose(); + _endFocus.dispose(); + _start.dispose(); + _end.dispose(); + super.dispose(); + } + @override - Widget build(BuildContext context) => AppRoutePage( - title: context.l10n.settingsDeadlineCutoff, - child: ValueListenableBuilder( - valueListenable: settings.deadlineCutoff, - builder: (context, window, _) { - final l10n = context.l10n; - final hintStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ); - return ListView( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + Widget build(BuildContext context) { + final l10n = context.l10n; + final editingStart = _editingStart; + final hintStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ); + Widget input({required bool start}) => TextField( + key: ValueKey(start ? 'deadline-cutoff-start' : 'deadline-cutoff-end'), + controller: start ? _start : _end, + focusNode: start ? _startFocus : _endFocus, + keyboardType: TextInputType.datetime, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelText: start ? l10n.deadlineCutoffStart : l10n.deadlineCutoffEnd, + hintText: 'HH:mm', + prefixIcon: const Icon(Icons.schedule_rounded), + errorText: (start ? _startInvalid : _endInvalid) + ? MaterialLocalizations.of(context).invalidTimeLabel + : null, + ), + onSubmitted: (_) => _commitInput(start: start), + ); + return AppRoutePage( + title: l10n.settingsDeadlineCutoff, + child: SingleChildScrollView( + padding: const EdgeInsets.only(top: 12, bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SwitchListTile( - key: const ValueKey('deadline-cutoff-enabled'), - contentPadding: EdgeInsets.zero, - title: Text(l10n.deadlineCutoffEnabled), - subtitle: Text(l10n.deadlineCutoffDescription), - value: window.enabled, - onChanged: (enabled) => settings.setDeadlineCutoff( - window.copyWith(enabled: enabled), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SwitchListTile( + key: const ValueKey('deadline-cutoff-enabled'), + contentPadding: EdgeInsets.zero, + title: Text(l10n.deadlineCutoffEnabled), + subtitle: Text(l10n.deadlineCutoffDescription), + value: _window.enabled, + onChanged: (enabled) => widget.settings.setDeadlineCutoff( + widget.settings.deadlineCutoff.value.copyWith( + enabled: enabled, + ), + ), + ), + const SizedBox(height: 12), + Text( + _windowSummary(context, _window), + key: const ValueKey('deadline-cutoff-summary'), + style: hintStyle, + ), + const SizedBox(height: 20), + input(start: true), + const SizedBox(height: 16), + input(start: false), + const SizedBox(height: 20), + SegmentedButton( + key: const ValueKey('deadline-cutoff-endpoint'), + showSelectedIcon: false, + segments: [ + ButtonSegment( + value: true, + label: Text( + l10n.deadlineCutoffStart, + textAlign: TextAlign.center, + ), + ), + ButtonSegment( + value: false, + label: Text( + l10n.deadlineCutoffEnd, + textAlign: TextAlign.center, + ), + ), + ], + selected: {_editingStart}, + onSelectionChanged: (selection) => + _selectEndpoint(selection.single), + ), + const SizedBox(height: 8), + ], ), ), - const SizedBox(height: 12), - Text(_windowSummary(context, window), style: hintStyle), - const SizedBox(height: 12), - ListTile( - key: const ValueKey('deadline-cutoff-start'), - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.schedule_rounded), - title: Text(l10n.deadlineCutoffStart), - subtitle: Text(_clock(context, window.startMinute)), - trailing: const Icon(Icons.edit_outlined), - onTap: () => _selectTime(context, start: true), + InlineDeadlineTimePicker( + key: const ValueKey('deadline-cutoff-clock'), + minute: _editingStart ? _window.startMinute : _window.endMinute, + endpoint: _editingStart, + helpText: _editingStart + ? l10n.deadlineCutoffStart + : l10n.deadlineCutoffEnd, + onSelected: (value) => _selectTime(value, start: editingStart), + onCancelled: () => _cancelTime(start: editingStart), ), - ListTile( - key: const ValueKey('deadline-cutoff-end'), - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.schedule_rounded), - title: Text(l10n.deadlineCutoffEnd), - subtitle: Text(_clock(context, window.endMinute)), - trailing: const Icon(Icons.edit_outlined), - onTap: () => _selectTime(context, start: false), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 12), + Text(l10n.deadlineCutoffBoundaryHint, style: hintStyle), + const SizedBox(height: 8), + Text(l10n.deadlineCutoffPreserveHint, style: hintStyle), + ], + ), ), - const SizedBox(height: 12), - Text(l10n.deadlineCutoffBoundaryHint, style: hintStyle), - const SizedBox(height: 8), - Text(l10n.deadlineCutoffPreserveHint, style: hintStyle), ], - ); - }, + ), + ), + ); + } +} + +/// Embeds the real Material time picker, without copying its private dial. +/// +/// TimePickerDialog reports OK/Cancel through Navigator.pop. The nested +/// navigator has an inert anchor below its picker, so those actions never pop +/// the settings route. Each result resets the picker, keeping it resident. +class InlineDeadlineTimePicker extends StatefulWidget { + const InlineDeadlineTimePicker({ + super.key, + required this.minute, + required this.endpoint, + required this.helpText, + required this.onSelected, + required this.onCancelled, + }); + + final int minute; + final bool endpoint; + final String helpText; + final Future Function(TimeOfDay value) onSelected; + final VoidCallback onCancelled; + + @override + State createState() => _InlineDeadlineTimePickerState(); +} + +class _InlineDeadlineTimePickerState extends State { + int _generation = 0; + + @override + void didUpdateWidget(covariant InlineDeadlineTimePicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.minute != widget.minute || oldWidget.endpoint != widget.endpoint) { + _generation++; + } + } + + void _complete(TimeOfDay? selected, int generation) { + if (!mounted || generation != _generation) return; + if (selected != null) { + unawaited(widget.onSelected(selected)); + } else { + widget.onCancelled(); + } + setState(() => _generation++); + } + + Route _pickerRoute(int generation) { + final route = PageRouteBuilder( + settings: const RouteSettings(name: 'deadline-cutoff-inline-picker'), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (context, animation, secondaryAnimation) => TimePickerDialog( + initialTime: TimeOfDay(hour: widget.minute ~/ 60, minute: widget.minute % 60), + helpText: widget.helpText, + initialEntryMode: TimePickerEntryMode.dial, + orientation: Orientation.portrait, + ), + ); + unawaited(route.popped.then((selected) => _complete(selected, generation))); + return route; + } + + @override + Widget build(BuildContext context) => SizedBox( + height: 580.0 * + (MediaQuery.textScalerOf(context).scale(14) / 14).clamp(1.0, 1.1), + child: MediaQuery.removeViewInsets( + context: context, + removeBottom: true, + child: HeroControllerScope.none( + child: Navigator( + key: ValueKey(_generation), + requestFocus: false, + onGenerateInitialRoutes: (_, initialRoute) => [ + PageRouteBuilder( + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (context, animation, secondaryAnimation) => + const SizedBox.shrink(), + ), + _pickerRoute(_generation), + ], + onGenerateRoute: (_) => _pickerRoute(_generation), + ), + ), ), ); } diff --git a/test/deadline_cutoff_settings_test.dart b/test/deadline_cutoff_settings_test.dart index 100f11b..9adc88d 100644 --- a/test/deadline_cutoff_settings_test.dart +++ b/test/deadline_cutoff_settings_test.dart @@ -96,6 +96,62 @@ Future _showCutoff( await tester.pumpAndSettle(); } +Future _expectSummary( + WidgetTester tester, + String Function(AppLocalizations l10n, MaterialLocalizations material) expected, +) async { + final summary = find.byKey(const ValueKey('deadline-cutoff-summary')); + await tester.ensureVisible(summary); + await tester.pumpAndSettle(); + final context = tester.element(summary); + expect( + tester.widget(summary).data, + expected(AppLocalizations.of(context), MaterialLocalizations.of(context)), + ); +} + +String _formattedTime(MaterialLocalizations material, int hour, int minute) => + material.formatTimeOfDay( + TimeOfDay(hour: hour, minute: minute), + alwaysUse24HourFormat: true, + ); + +Future _selectEndpoint(WidgetTester tester, {required bool start}) async { + final context = tester.element(find.byType(DeadlineCutoffScreen)); + final l10n = AppLocalizations.of(context); + final target = find.descendant( + of: find.byKey(const ValueKey('deadline-cutoff-endpoint')), + matching: find.text(start ? l10n.deadlineCutoffStart : l10n.deadlineCutoffEnd), + ); + await tester.ensureVisible(target); + await tester.tap(target); + await tester.pumpAndSettle(); +} + +Future _nativeInputMode(WidgetTester tester) async { + final context = tester.element(find.byType(TimePickerDialog)); + final button = find.byTooltip( + MaterialLocalizations.of(context).inputTimeModeButtonLabel, + ); + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pumpAndSettle(); +} + +Future _pickerAction(WidgetTester tester, {required bool confirm}) async { + final context = tester.element(find.byType(TimePickerDialog)); + final material = MaterialLocalizations.of(context); + final button = find.descendant( + of: find.byType(TimePickerDialog), + matching: find.text( + confirm ? material.okButtonLabel : material.cancelButtonLabel, + ), + ); + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pumpAndSettle(); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -257,7 +313,19 @@ void main() { ).value, isFalse, ); - expect(find.text(l10n.deadlineCutoffOvernight('22:00', '08:00')), findsOneWidget); + expect(find.byType(TimePickerDialog), findsOneWidget); + expect( + tester.widget(find.byType(TimePickerDialog)) + .initialEntryMode, + TimePickerEntryMode.dial, + ); + expect(find.byKey(const ValueKey('deadline-cutoff-start')), findsOneWidget); + expect(find.byKey(const ValueKey('deadline-cutoff-end')), findsOneWidget); + await _expectSummary(tester, (l10n, material) => + l10n.deadlineCutoffOvernight( + _formattedTime(material, 22, 0), + _formattedTime(material, 8, 0), + )); final switcher = find.byKey(const ValueKey('deadline-cutoff-enabled')); await tester.ensureVisible(switcher); await tester.tap(switcher); @@ -273,18 +341,23 @@ void main() { const DeadlineCutoffWindow(startMinute: 600, endMinute: 720), ); await tester.pumpAndSettle(); - expect(find.text(l10n.deadlineCutoffSameDay('10:00', '12:00')), findsOneWidget); + await _expectSummary(tester, (l10n, material) => + l10n.deadlineCutoffSameDay( + _formattedTime(material, 10, 0), + _formattedTime(material, 12, 0), + )); await settings.setDeadlineCutoff( const DeadlineCutoffWindow(startMinute: 600, endMinute: 600), ); await tester.pumpAndSettle(); - expect(find.text(l10n.deadlineCutoffInstant('10:00')), findsOneWidget); + await _expectSummary(tester, (l10n, material) => + l10n.deadlineCutoffInstant(_formattedTime(material, 10, 0))); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox()); }); } - testWidgets('Canceling either time picker does not persist a cutoff', ( + testWidgets('Cancel resets either resident Material picker without saving', ( tester, ) async { final preferences = MemoryPreferences(); @@ -292,16 +365,26 @@ void main() { addTearDown(settings.dispose); await settings.initialize(); await _showCutoff(tester, settings); - for (final name in ['deadline-cutoff-start', 'deadline-cutoff-end']) { - final tile = find.byKey(ValueKey(name)); - await tester.ensureVisible(tile); - await tester.tap(tile); - await tester.pumpAndSettle(); + // The native component exists immediately, before any field/button tap. + expect(find.byType(TimePickerDialog), findsOneWidget); + for (final start in [true, false]) { + await _selectEndpoint(tester, start: start); + await _nativeInputMode(tester); + final inputs = find.descendant( + of: find.byType(TimePickerDialog), + matching: find.byType(TextField), + ); + expect(inputs, findsNWidgets(2)); + await tester.enterText(inputs.first, '03'); + await tester.enterText(inputs.last, '45'); + expect(preferences.value(_key), isNull); + await _pickerAction(tester, confirm: false); + expect(find.byType(DeadlineCutoffScreen), findsOneWidget); expect(find.byType(TimePickerDialog), findsOneWidget); - final pickerContext = tester.element(find.byType(TimePickerDialog)); - await tester.tap(find.text(MaterialLocalizations.of(pickerContext).cancelButtonLabel)); - await tester.pumpAndSettle(); - expect(find.byType(TimePickerDialog), findsNothing); + expect( + tester.widget(find.byType(TimePickerDialog)).initialTime, + TimeOfDay(hour: start ? 22 : 8, minute: 0), + ); expect(settings.deadlineCutoff.value, const DeadlineCutoffWindow()); expect(preferences.value(_key), isNull); } @@ -309,7 +392,7 @@ void main() { await tester.pumpWidget(const SizedBox()); }); - testWidgets('Time picker changes only the selected endpoint and saves it', ( + testWidgets('Resident picker confirms only its endpoint using latest settings', ( tester, ) async { final preferences = MemoryPreferences(); @@ -317,28 +400,153 @@ void main() { addTearDown(settings.dispose); await settings.initialize(); await _showCutoff(tester, settings); - final tile = find.byKey(const ValueKey('deadline-cutoff-start')); - await tester.tap(tile); - await tester.pumpAndSettle(); + await _nativeInputMode(tester); + final pickerElement = tester.element(find.byType(TimePickerDialog)); final inputs = find.descendant( of: find.byType(TimePickerDialog), matching: find.byType(TextField), ); expect(inputs, findsNWidgets(2)); - // Preserve changes made while the picker is open, not its old snapshot. + await tester.enterText(inputs.first, '21'); + await tester.enterText(inputs.last, '15'); + // Enabled/opposite-endpoint updates preserve the native picker's draft. await settings.setDeadlineCutoff( const DeadlineCutoffWindow(enabled: true, endMinute: 600), ); await tester.pump(); - await tester.enterText(inputs.first, '21'); - await tester.enterText(inputs.last, '15'); - final pickerContext = tester.element(find.byType(TimePickerDialog)); - await tester.tap(find.text(MaterialLocalizations.of(pickerContext).okButtonLabel)); - await tester.pumpAndSettle(); + expect(tester.element(find.byType(TimePickerDialog)), same(pickerElement)); + await _pickerAction(tester, confirm: true); expect(settings.deadlineCutoff.value.startMinute, 21 * 60 + 15); expect(settings.deadlineCutoff.value.endMinute, 600); expect(settings.deadlineCutoff.value.enabled, isTrue); expect(jsonDecode(preferences.value(_key)!), settings.deadlineCutoff.value.toJson()); + expect(find.byType(DeadlineCutoffScreen), findsOneWidget); + expect(find.byType(TimePickerDialog), findsOneWidget); + expect( + tester.widget(find.byKey(const ValueKey('deadline-cutoff-start'))) + .controller!.text, + '21:15', + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('Resident text fields validate before saving and reset the dial', ( + tester, + ) async { + final preferences = MemoryPreferences(); + final settings = _settings(preferences); + addTearDown(settings.dispose); + await settings.initialize(); + await _showCutoff(tester, settings); + final input = find.byKey(const ValueKey('deadline-cutoff-start')); + await tester.ensureVisible(input); + await tester.enterText(input, '25:10'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + final material = MaterialLocalizations.of(tester.element(input)); + expect(find.text(material.invalidTimeLabel), findsOneWidget); + expect(settings.deadlineCutoff.value.startMinute, 22 * 60); + expect(preferences.value(_key), isNull); + + await tester.enterText(input, '21:'); + await tester.pump(); + expect(preferences.value(_key), isNull); + await tester.enterText(input, '21:15'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(settings.deadlineCutoff.value.startMinute, 21 * 60 + 15); + expect(settings.deadlineCutoff.value.enabled, isFalse); + expect(settings.deadlineCutoff.value.endMinute, 8 * 60); + expect(find.text(material.invalidTimeLabel), findsNothing); + expect( + tester.widget(find.byType(TimePickerDialog)).initialTime, + const TimeOfDay(hour: 21, minute: 15), + ); + expect(find.byType(TimePickerDialog), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('The same native picker can switch between dial and input modes', ( + tester, + ) async { + final settings = _settings(MemoryPreferences()); + addTearDown(settings.dispose); + await settings.initialize(); + await _showCutoff(tester, settings); + final pickerElement = tester.element(find.byType(TimePickerDialog)); + await _nativeInputMode(tester); + expect(tester.element(find.byType(TimePickerDialog)), same(pickerElement)); + expect( + find.descendant( + of: find.byType(TimePickerDialog), + matching: find.byType(TextField), + ), + findsNWidgets(2), + ); + final context = tester.element(find.byType(TimePickerDialog)); + final dialButton = find.byTooltip( + MaterialLocalizations.of(context).dialModeButtonLabel, + ); + await tester.ensureVisible(dialButton); + await tester.tap(dialButton); + await tester.pumpAndSettle(); + expect(tester.element(find.byType(TimePickerDialog)), same(pickerElement)); + expect( + find.descendant( + of: find.byType(TimePickerDialog), + matching: find.byType(TextField), + ), + findsNothing, + ); + expect(find.byKey(const ValueKey('deadline-cutoff-start')), findsOneWidget); + expect(find.byKey(const ValueKey('deadline-cutoff-end')), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('Native picker actions never pop the surrounding settings route', ( + tester, + ) async { + final settings = _settings(MemoryPreferences()); + addTearDown(settings.dispose); + await settings.initialize(); + await tester.pumpWidget(MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('zh'), + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DeadlineCutoffScreen(settings: settings), + ), + ), + child: const Text('打开设置'), + ), + ), + ), + ), + )); + await tester.tap(find.text('打开设置')); + await tester.pumpAndSettle(); + expect(find.byType(TimePickerDialog), findsOneWidget); + await _pickerAction(tester, confirm: false); + expect(find.byType(DeadlineCutoffScreen), findsOneWidget); + expect(find.byType(TimePickerDialog), findsOneWidget); + await _pickerAction(tester, confirm: true); + expect(find.byType(DeadlineCutoffScreen), findsOneWidget); + expect(find.byType(TimePickerDialog), findsOneWidget); + final back = find.byType(BackButton); + await tester.ensureVisible(back); + await tester.tap(back); + await tester.pumpAndSettle(); + expect(find.byType(DeadlineCutoffScreen), findsNothing); + expect(find.text('打开设置'), findsOneWidget); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox()); }); From 150af823ec497a686816373d98166450dfd62cba Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Wed, 11 Nov 2026 03:06:58 +0800 Subject: [PATCH 05/24] feat(agenda): unify event and exam timeline styling --- lib/agenda/timeline_layout.dart | 10 +- lib/agenda/ui/adjusted_deadline_painter.dart | 10 +- lib/agenda/ui/agenda_timeline.dart | 39 +++- lib/agenda/ui/event_timeline_entry.dart | 233 +++++++++++++++++++ lib/agenda/ui/timeline_colors.dart | 32 +-- test/agenda_timeline_colors_test.dart | 31 +++ test/agenda_timeline_test.dart | 131 ++++++++--- test/event_timeline_style_test.dart | 136 +++++++++++ 8 files changed, 559 insertions(+), 63 deletions(-) create mode 100644 lib/agenda/ui/event_timeline_entry.dart create mode 100644 test/event_timeline_style_test.dart diff --git a/lib/agenda/timeline_layout.dart b/lib/agenda/timeline_layout.dart index 64ecb91..d6750f4 100644 --- a/lib/agenda/timeline_layout.dart +++ b/lib/agenda/timeline_layout.dart @@ -30,10 +30,10 @@ List layoutAgendaTimeline( } // All deadline types share a global reservation. Legacy per-entry tailDays // remains stored, but no longer controls layout or painted width. - DateTime reservationStart(AgendaEntry entry) => - entry.isDeadline && !entry.startOnly - ? entry.dueAt.subtract(Duration(days: layoutDays)) - : entry.startsAt; + DateTime reservationStart(AgendaEntry entry) => entry.startOnly + ? entry.startsAt + : (entry.isDeadline ? entry.dueAt : entry.startsAt) + .subtract(Duration(days: layoutDays)); DateTime reservationEnd(AgendaEntry entry) => entry.isDeadline ? entry.startOnly ? entry.startsAt.add(Duration(days: layoutDays)) @@ -56,7 +56,7 @@ List layoutAgendaTimeline( final laneEnds = []; final result = []; for (final interval in intervals) { - final minimumEnd = interval.start.add(minimumEventSpan); + final minimumEnd = interval.entry.startsAt.add(minimumEventSpan); final layoutEnd = !interval.entry.isDeadline && interval.end.isBefore(minimumEnd) ? minimumEnd diff --git a/lib/agenda/ui/adjusted_deadline_painter.dart b/lib/agenda/ui/adjusted_deadline_painter.dart index 40860ca..2a1d7d6 100644 --- a/lib/agenda/ui/adjusted_deadline_painter.dart +++ b/lib/agenda/ui/adjusted_deadline_painter.dart @@ -24,12 +24,16 @@ class AdjustedDeadlineExtensionPainter extends CustomPainter { required this.realX, required this.color, this.stripeWidth = 16, + this.highOpacity = 0.32, + this.lowOpacity = 0.16, }); final double effectiveX; final double realX; final Color color; final double stripeWidth; + final double highOpacity; + final double lowOpacity; @override void paint(Canvas canvas, Size size) { @@ -54,7 +58,7 @@ class AdjustedDeadlineExtensionPainter extends CustomPainter { path, Paint() ..color = color.withValues( - alpha: color.a * (index.isEven ? 0.32 : 0.16), + alpha: color.a * (index.isEven ? highOpacity : lowOpacity), ), ); } @@ -66,7 +70,9 @@ class AdjustedDeadlineExtensionPainter extends CustomPainter { oldDelegate.effectiveX != effectiveX || oldDelegate.realX != realX || oldDelegate.color != color || - oldDelegate.stripeWidth != stripeWidth; + oldDelegate.stripeWidth != stripeWidth || + oldDelegate.highOpacity != highOpacity || + oldDelegate.lowOpacity != lowOpacity; } /// Always used as a foreground painter: even a subpixel interval must draw the diff --git a/lib/agenda/ui/agenda_timeline.dart b/lib/agenda/ui/agenda_timeline.dart index c5a3729..9a73406 100644 --- a/lib/agenda/ui/agenda_timeline.dart +++ b/lib/agenda/ui/agenda_timeline.dart @@ -13,6 +13,7 @@ import '../model.dart'; import '../timeline_layout.dart'; import 'adjusted_deadline_painter.dart'; import 'agenda_detail.dart'; +import 'event_timeline_entry.dart'; import 'timeline_entry.dart'; const _microsecondsPerHour = Duration.microsecondsPerSecond * 3600; @@ -310,11 +311,16 @@ class _AgendaTimelineState extends State // `deadlineWidth` wide, so card culling follows the painted box rather // than the wider reservation. This does not change the scroll range. DateTime paintedStart(AgendaTimelinePlacement placement) => - placement.entry.isDeadline && !placement.entry.startOnly - ? placement.entry.dueAt.subtract(deadlineDuration(placement.entry)) - : placement.start; + placement.entry.startOnly + ? placement.start + : (placement.entry.isDeadline + ? placement.entry.dueAt + : placement.entry.startsAt) + .subtract(deadlineDuration(placement.entry)); DateTime paintedEnd(AgendaTimelinePlacement placement) => - placement.entry.isDeadline && placement.entry.startOnly + !placement.entry.isDeadline + ? placement.entry.endsAt! + : placement.entry.startOnly ? placement.start.add(deadlineDuration(placement.entry)) : placement.layoutEnd; final minimumEventSpan = Duration( @@ -676,6 +682,31 @@ class _AgendaTimelineState extends State time.difference(_center).inMicroseconds / _microsecondsPerHour * pixelsPerHour; + if (!entry.isDeadline) { + final start = x(entry.startsAt); + final end = x(entry.endsAt!); + final informationLeft = start - deadlineWidth; + final left = math.max(0.0, informationLeft); + final right = math.min(width, end); + return Positioned( + key: ValueKey(('agenda-timeline-entry', entry.id)), + left: left, + top: placement.lane * laneHeight + 12, + width: math.max(0.0, right - left), + height: laneHeight - 12, + child: EventTimelineEntry( + entry: entry, + now: widget.now, + informationWidth: deadlineWidth, + geometry: EventTimelineGeometry( + informationLeft: informationLeft - left, + startX: start - left, + endX: end - left, + ), + onTap: () => showAgendaEntry(context, widget.controller, entry), + ), + ); + } if (entry.hasDeadlineCutoff) { final effective = x(entry.dueAt); final real = x(entry.realDueAt); diff --git a/lib/agenda/ui/event_timeline_entry.dart b/lib/agenda/ui/event_timeline_entry.dart new file mode 100644 index 0000000..db4c3f7 --- /dev/null +++ b/lib/agenda/ui/event_timeline_entry.dart @@ -0,0 +1,233 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../core/time.dart'; +import '../../l10n/formats.dart'; +import '../../l10n/l10n.dart'; +import '../model.dart'; +import 'adjusted_deadline_painter.dart'; +import 'timeline_colors.dart'; + +/// All coordinates are relative to a viewport-bounded card surface. +class EventTimelineGeometry { + const EventTimelineGeometry({ + required this.informationLeft, + required this.startX, + required this.endX, + }); + + final double informationLeft; + final double startX; + final double endX; +} + +/// Event information has the same fixed screen width as a DDL tail. The actual +/// event interval retains its time-scaled width, independently of the text. +class EventTimelinePainter extends CustomPainter { + const EventTimelinePainter({ + required this.geometry, + required this.fill, + required this.accent, + this.lineWidth = 3, + }); + + final EventTimelineGeometry geometry; + final Color fill; + final Color accent; + final double lineWidth; + + @override + void paint(Canvas canvas, Size size) { + if (size.isEmpty) return; + canvas.save(); + canvas.clipRect(Offset.zero & size); + final information = Rect.fromLTRB( + geometry.informationLeft, 0, geometry.startX, size.height, + ); + // Palette alpha already contains completion fading. Apply the information + // region's extra half without replacing (and losing) the existing alpha. + AdjustedDeadlineExtensionPainter( + effectiveX: geometry.informationLeft, + realX: geometry.startX, + color: fill, + highOpacity: .5, + lowOpacity: .25, + ).paint(canvas, size); + final borderWidth = lineWidth / 2; + if (information.width >= borderWidth && size.height >= borderWidth) { + canvas.drawRect( + information.deflate(borderWidth / 2), + Paint() + ..color = fill + ..style = PaintingStyle.stroke + ..strokeWidth = borderWidth, + ); + } + final intervalLeft = math.max(0.0, geometry.startX); + final intervalRight = math.min(size.width, geometry.endX); + if (intervalRight > intervalLeft) { + canvas.drawRect( + Rect.fromLTRB(intervalLeft, 0, intervalRight, size.height), + Paint()..color = fill, + ); + } + for (final x in [geometry.startX, geometry.endX]) { + canvas.drawRect( + Rect.fromLTRB(x - lineWidth, 0, x, size.height), + Paint()..color = accent, + ); + } + canvas.restore(); + } + + @override + bool shouldRepaint(EventTimelinePainter oldDelegate) => + oldDelegate.geometry.informationLeft != geometry.informationLeft || + oldDelegate.geometry.startX != geometry.startX || + oldDelegate.geometry.endX != geometry.endX || + oldDelegate.fill != fill || + oldDelegate.accent != accent || + oldDelegate.lineWidth != lineWidth; +} + +class EventTimelineEntry extends StatelessWidget { + const EventTimelineEntry({ + super.key, + required this.entry, + required this.now, + required this.geometry, + required this.informationWidth, + required this.onTap, + }); + + final AgendaEntry entry; + final DateTime now; + final EventTimelineGeometry geometry; + final double informationWidth; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final colors = agendaTimelineColors(context, entry, now); + final overdue = entry.overdue(now); + final title = localizedAgendaTitle(l10n, entry); + final preview = [ + '${entry.kind.localizedLabel(l10n)} · $title', + l10n.agendaTimelineStarts(localizedAgendaDateTime(l10n, entry.startsAt)), + l10n.agendaTimelineEnds(localizedAgendaDateTime(l10n, entry.endsAt!)), + if (entry.course.isNotEmpty) l10n.agendaTimelineCourse(entry.course), + if (entry.location.isNotEmpty) l10n.agendaTimelineLocation(entry.location), + if (entry.done) l10n.agendaTimelineStatusCompleted + else if (overdue) l10n.agendaTimelineStatusOverdue, + ].join('\n'); + final caption = overdue + ? l10n.agendaReminderOverdue(clockText(entry.endsAt!)) + : _eventCaption(l10n, entry); + final semantics = l10n.agendaTimelineSemanticsEntry( + entry.kind.localizedLabel(l10n), + title, + '${localizedAgendaDateTime(l10n, entry.startsAt)} ' + '${_eventCaption(l10n, entry).replaceAll('\n', ' ')}', + entry.course.isEmpty ? '' : l10n.listSeparator + entry.course, + entry.location.isEmpty ? '' : l10n.listSeparator + entry.location, + entry.done + ? l10n.listSeparator + l10n.agendaTimelineStatusCompleted + : overdue + ? l10n.listSeparator + l10n.agendaTimelineStatusOverdue + : '', + ); + return Semantics( + button: true, + label: semantics, + onTap: onTap, + child: ExcludeSemantics( + child: Tooltip( + message: preview, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: ClipRect( + child: CustomPaint( + painter: EventTimelinePainter( + geometry: geometry, + fill: colors.fill, + accent: colors.accent, + ), + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned( + left: geometry.informationLeft, + top: 0, + bottom: 0, + width: informationWidth, + child: SizedBox( + key: ValueKey(('agenda-timeline-bar', entry.id)), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 14, + height: 1.2, + fontWeight: FontWeight.w600, + color: colors.text, + decoration: entry.done + ? TextDecoration.lineThrough + : null, + ), + ), + const SizedBox(height: 2), + Text( + caption, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 12, + height: 1.2, + fontWeight: FontWeight.w600, + color: colors.accent, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +String _eventCaption(AppLocalizations l10n, AgendaEntry entry) { + String day(DateTime value) => '${value.month}/${value.day}'; + final end = entry.endsAt!; + if (entry.allDay) { + final last = end.subtract(const Duration(days: 1)); + return calendarDate(last) == calendarDate(entry.startsAt) + ? l10n.allDay + : '${day(entry.startsAt)}–${day(last)}\n${l10n.allDay}'; + } + if (calendarDate(entry.startsAt) == calendarDate(end)) { + return '${clockText(entry.startsAt)}–${clockText(end)}'; + } + return '${day(entry.startsAt)} ${clockText(entry.startsAt)}\n' + '→ ${day(end)} ${clockText(end)}'; +} diff --git a/lib/agenda/ui/timeline_colors.dart b/lib/agenda/ui/timeline_colors.dart index fe917f7..7f3c415 100644 --- a/lib/agenda/ui/timeline_colors.dart +++ b/lib/agenda/ui/timeline_colors.dart @@ -97,24 +97,25 @@ enum _TimelineTone { null => null, }, }; - final finished = entry.isFinished(now); - final fadeAll = - finished && (entry.isDeadline || entry.kind == AgendaKind.exam); - final fillAlpha = fadeAll ? 0.5 : (finished ? 0.55 : 1.0); - final foregroundAlpha = fadeAll ? 0.5 : 1.0; + final finished = entry.done || entry.isFinished(now); + final fillAlpha = finished ? 0.5 : 1.0; + final foregroundAlpha = finished ? 0.5 : 1.0; late final _TimelineColors colors; if (tone != null) { final values = theme.brightness == Brightness.dark ? tone.dark : tone.light; colors = ( - fill: values.fill.withValues(alpha: fillAlpha), - text: values.text.withValues(alpha: foregroundAlpha), - accent: values.accent.withValues(alpha: foregroundAlpha), + fill: values.fill.withValues(alpha: values.fill.a * fillAlpha), + text: values.text.withValues(alpha: values.text.a * foregroundAlpha), + accent: values.accent.withValues(alpha: values.accent.a * foregroundAlpha), ); } else if (agendaPaletteColors(theme) case final blockPalette?) { + final fill = Color(blockPalette.fill); + final text = Color(blockPalette.text); + final accent = Color(blockPalette.accent); colors = ( - fill: Color(blockPalette.fill).withValues(alpha: fillAlpha), - text: Color(blockPalette.text).withValues(alpha: foregroundAlpha), - accent: Color(blockPalette.accent).withValues(alpha: foregroundAlpha), + fill: fill.withValues(alpha: fill.a * fillAlpha), + text: text.withValues(alpha: text.a * foregroundAlpha), + accent: accent.withValues(alpha: accent.a * foregroundAlpha), ); } else { final manualDeadline = @@ -129,12 +130,11 @@ enum _TimelineTone { : manualDeadline ? scheme.onSecondaryContainer : scheme.onPrimaryContainer; + final accent = manualDeadline ? scheme.secondary : scheme.primary; colors = ( - fill: fill.withValues(alpha: fillAlpha), - text: text.withValues(alpha: foregroundAlpha), - accent: (manualDeadline ? scheme.secondary : scheme.primary).withValues( - alpha: foregroundAlpha, - ), + fill: fill.withValues(alpha: fill.a * fillAlpha), + text: text.withValues(alpha: text.a * foregroundAlpha), + accent: accent.withValues(alpha: accent.a * foregroundAlpha), ); } diff --git a/test/agenda_timeline_colors_test.dart b/test/agenda_timeline_colors_test.dart index ee62088..355a1f3 100644 --- a/test/agenda_timeline_colors_test.dart +++ b/test/agenda_timeline_colors_test.dart @@ -95,5 +95,36 @@ void main() { expect(finishedColors.accent.a, closeTo(0.5, 0.01)); } }); + + testWidgets('Completed events and exams fade fill, text and accent in $brightness', ( + tester, + ) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(brightness), + home: Builder( + builder: (value) { + context = value; + return const SizedBox(); + }, + ), + ), + ); + for (final entry in entries.where((entry) => !entry.isDeadline)) { + // Keep the test clock before the event starts: this verifies the + // completed palette, not automatic completion or overdue transitions. + final pending = agendaTimelineColors(context, entry, now); + final completed = agendaTimelineColors( + context, + entry.copyWith(done: true), + now, + ); + expect(completed.fill.a, closeTo(pending.fill.a * .5, .01)); + expect(completed.text.a, closeTo(pending.text.a * .5, .01)); + expect(completed.accent.a, closeTo(pending.accent.a * .5, .01)); + } + expect(tester.takeException(), isNull); + }); } } diff --git a/test/agenda_timeline_test.dart b/test/agenda_timeline_test.dart index cac96cf..5567654 100644 --- a/test/agenda_timeline_test.dart +++ b/test/agenda_timeline_test.dart @@ -1,6 +1,7 @@ import 'package:bitree/agenda/model.dart'; import 'package:bitree/agenda/timeline_layout.dart'; import 'package:bitree/agenda/ui/agenda_timeline.dart'; +import 'package:bitree/agenda/ui/event_timeline_entry.dart'; import 'package:bitree/l10n/app_localizations_zh.dart'; import 'package:bitree/l10n/formats.dart'; import 'package:bitree/ui/theme.dart'; @@ -29,6 +30,16 @@ Finder _bar(AgendaEntry entry) => find.bySemanticsLabel( Finder _paintedBar(AgendaEntry entry) => find.byKey(ValueKey(('agenda-timeline-bar', entry.id))); +String _startPreview(AgendaEntry entry) { + final l10n = AppLocalizationsZh(); + return l10n.agendaTimelineStarts(localizedAgendaDateTime(l10n, entry.startsAt)); +} + +String _endPreview(AgendaEntry entry) { + final l10n = AppLocalizationsZh(); + return l10n.agendaTimelineEnds(localizedAgendaDateTime(l10n, entry.endsAt!)); +} + List _stackedDeadlines() => [ for (var index = 0; index < 24; index++) AgendaEntry( @@ -84,7 +95,7 @@ Future _show( } void main() { - test('Minimum display spans separate short events without changing their real times', () { + test('Event information reservations keep real endpoints and minimum hit spans', () { final first = _event('first', 11); final second = _event('second', 14, exam: true); final entries = [first, second]; @@ -93,7 +104,10 @@ void main() { minimumEventSpan: const Duration(hours: 48), ); expect(zoomedOut.map((placement) => placement.lane), [0, 1]); - expect(zoomedOut.first.start, first.startsAt); + expect( + zoomedOut.first.start, + first.startsAt.subtract(const Duration(days: 4)), + ); expect(zoomedOut.first.end, first.endsAt); expect( zoomedOut.first.layoutEnd, @@ -103,7 +117,17 @@ void main() { entries, minimumEventSpan: const Duration(hours: 2), ); - expect(zoomedIn.map((placement) => placement.lane), [0, 0]); + // Their real intervals do not overlap, but the four-day information + // reservations do, independently of the minimum hit-target span. + expect(zoomedIn.map((placement) => placement.lane), [0, 1]); + expect( + zoomedIn.first.start, + first.startsAt.subtract(const Duration(days: 4)), + ); + expect( + zoomedIn.first.layoutEnd, + first.startsAt.add(const Duration(hours: 2)), + ); expect(zoomedIn.map((placement) => placement.end), [ first.endsAt, second.endsAt, @@ -134,6 +158,8 @@ void main() { final deadlinePlacement = placements.singleWhere( (placement) => placement.entry == ddl, ); + expect(eventPlacement.start, long.startsAt.subtract(const Duration(days: 4))); + expect(eventPlacement.end, long.endsAt); expect(eventPlacement.layoutEnd, long.endsAt); expect( deadlinePlacement.start, @@ -216,9 +242,11 @@ void main() { (Brightness.dark, 1.6), ]) { testWidgets( - 'Short cards keep a fixed 2px minimum and stay tappable in $brightness, $scale', + 'Short events keep fixed information cards and real time spans in $brightness, $scale', (tester) async { - final first = _event('first', 11); + // Keep these intervals in the future at the fixed test clock so their + // range caption is tested independently of overdue status formatting. + final first = _event('first', 13); final second = _event('second', 14, exam: true); final ddl = AgendaEntry( id: 'ddl', @@ -233,21 +261,14 @@ void main() { scale: scale, brightness: brightness, ); - final viewport = tester.getRect(find.byType(AgendaTimeline)); - void checkCards(double eventWidth) { + void checkCards(double realIntervalWidth) { final rectangles = entries .map((entry) => tester.getRect(_paintedBar(entry))) .toList(); - expect(rectangles.first.width, closeTo(eventWidth, .001)); - expect( - rectangles[1].width, - closeTo( - eventWidth.clamp(0, viewport.right - rectangles[1].left), - .001, - ), - ); - expect(rectangles.last.width, closeTo(180, .001)); for (var index = 0; index < rectangles.length; index++) { + // Event/exam information regions now use the same fixed-width + // screen-space reservation as the DDL, not a 2px text box. + expect(rectangles[index].width, closeTo(180, .001)); expect( rectangles[index].height, closeTo(tester.getRect(_bar(entries[index])).height, .001), @@ -256,18 +277,36 @@ void main() { expect(rectangles[index].overlaps(rectangles[other]), isFalse); } } + for (final entry in [first, second]) { + final card = tester.widget( + find.byWidgetPredicate( + (widget) => widget is EventTimelineEntry && + widget.entry.id == entry.id, + ), + ); + expect(card.informationWidth, closeTo(180, .001)); + expect( + card.geometry.startX - card.geometry.informationLeft, + closeTo(180, .001), + ); + expect( + card.geometry.endX - card.geometry.startX, + closeTo(realIntervalWidth, .001), + ); + } expect( tester.getSemantics(_bar(first)).label, - contains('11:00–11:05'), + contains('13:00–13:05'), ); expect(tester.takeException(), isNull); } - checkCards(2); + // At minimum zoom the five-minute real interval is below 2px. Its + // markers stay at the actual times while the information area is wide. + checkCards(2 * 5 / 60); tester.widget(find.byType(Slider)).onChanged!(24); await tester.pumpAndSettle(); - // At 48px/hour, the real five-minute duration is 4px, above the 2px floor. - checkCards(4); + checkCards(48 * 5 / 60); final short = tester.getRect(_bar(first)); await tester.tapAt(Offset(short.right - 12, short.center.dy)); await tester.pumpAndSettle(); @@ -279,7 +318,7 @@ void main() { findsWidgets, ); expect(app.agenda.entries, orderedEquals(entries)); - expect(first.endsAt, _date.add(const Duration(hours: 11, minutes: 5))); + expect(first.endsAt, _date.add(const Duration(hours: 13, minutes: 5))); await tester.tap(find.byTooltip('关闭')); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); @@ -289,26 +328,26 @@ void main() { } testWidgets( - 'A minimum-width card stays visible after its real end leaves the viewport', + 'A future event stays tappable while only its information tail is visible', (tester) async { - final beforeViewport = _date - .subtract(const Duration(days: 4)) - .add(const Duration(hours: 10)); + final afterViewport = _date.add(const Duration(days: 5, hours: 12)); final entry = AgendaEntry( id: 'edge', title: '边缘短日程', kind: AgendaKind.event, - startsAt: beforeViewport, - endsAt: beforeViewport.add(const Duration(minutes: 5)), + startsAt: afterViewport, + endsAt: afterViewport.add(const Duration(minutes: 5)), ); await _show(tester, [entry]); + final viewport = tester.getRect(find.byType(AgendaTimeline)); final painted = tester.getRect(_paintedBar(entry)); - expect(painted.left, 0); - // Half of the 2px bar is clipped at the left edge; its hit target is wider. - expect(painted.width, closeTo(1, .001)); + expect(painted.width, closeTo(180, .001)); + expect(painted.left, lessThan(viewport.right)); + expect(painted.right, greaterThan(viewport.right)); final card = tester.getRect(_bar(entry)); - expect(card.left, 0); - await tester.tapAt(card.center); + expect(card.right, lessThanOrEqualTo(viewport.right)); + expect(card.width, greaterThan(0)); + await tester.tapAt(Offset(viewport.right - 20, card.center.dy)); await tester.pumpAndSettle(); expect(find.byType(BottomSheet), findsOneWidget); expect(tester.takeException(), isNull); @@ -316,6 +355,26 @@ void main() { }, ); + testWidgets('An event wholly left of the viewport has no artificial 2px tail', ( + tester, + ) async { + final beforeViewport = _date + .subtract(const Duration(days: 4)) + .add(const Duration(hours: 10)); + final entry = AgendaEntry( + id: 'past-edge', + title: '屏外短日程', + kind: AgendaKind.event, + startsAt: beforeViewport, + endsAt: beforeViewport.add(const Duration(minutes: 5)), + ); + await _show(tester, [entry]); + expect(_paintedBar(entry), findsNothing); + expect(_bar(entry), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + testWidgets('Global lanes reserve space without rendering off-screen cards', ( tester, ) async { @@ -537,11 +596,11 @@ void main() { await tester.longPress(_bar(entry)); await tester.pumpAndSettle(); expect( - find.textContaining('开始:2026-11-17 周二 11:00', findRichText: true), + find.textContaining(_startPreview(entry), findRichText: true), findsOneWidget, ); expect( - find.textContaining('结束:2026-11-17 周二 13:00', findRichText: true), + find.textContaining(_endPreview(entry), findRichText: true), findsOneWidget, ); expect(find.byType(BottomSheet), findsNothing); @@ -623,11 +682,11 @@ void main() { await tester.pump(const Duration(milliseconds: 600)); await tester.pumpAndSettle(); expect( - find.textContaining('开始:2026-11-17 周二 11:00', findRichText: true), + find.textContaining(_startPreview(entry), findRichText: true), findsOneWidget, ); expect( - find.textContaining('结束:2026-11-17 周二 11:05', findRichText: true), + find.textContaining(_endPreview(entry), findRichText: true), findsOneWidget, ); expect(find.byType(BottomSheet), findsNothing); diff --git a/test/event_timeline_style_test.dart b/test/event_timeline_style_test.dart new file mode 100644 index 0000000..d9f5b30 --- /dev/null +++ b/test/event_timeline_style_test.dart @@ -0,0 +1,136 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:bitree/agenda/model.dart'; +import 'package:bitree/agenda/ui/agenda_timeline.dart'; +import 'package:bitree/agenda/ui/event_timeline_entry.dart'; +import 'package:bitree/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/test_app.dart'; + +final _date = DateTime.utc(2026, 11, 17); + +AgendaEntry _event({bool exam = false, bool done = false}) => AgendaEntry( + id: exam ? 'exam' : 'event', + title: exam ? '考试' : '日程', + kind: exam ? AgendaKind.exam : AgendaKind.event, + startsAt: _date.add(const Duration(hours: 11)), + endsAt: _date.add(const Duration(hours: 13)), + done: done, +); + +Future _pixels(WidgetTester tester, GlobalKey key) async => + (await tester.runAsync(() async { + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 1); + final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + image.dispose(); + return data!; + }))!; + +Future _show( + WidgetTester tester, + List entries, +) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final app = TestAppController(entries: entries); + await app.initialize(); + await tester.pumpWidget(MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold(body: AgendaTimeline( + controller: app, + entries: entries, + now: _date.add(const Duration(hours: 10)), + )), + )); + await tester.pumpAndSettle(); + return app; +} + +void main() { + for (final done in [false, true]) { + testWidgets('Event stripes multiply existing completion alpha: $done', ( + tester, + ) async { + final key = GlobalKey(); + final completionAlpha = done ? .5 : 1.0; + await tester.pumpWidget(MaterialApp( + home: Center(child: RepaintBoundary( + key: key, + child: SizedBox(width: 120, height: 40, child: CustomPaint( + painter: EventTimelinePainter( + geometry: const EventTimelineGeometry( + informationLeft: 0, startX: 80, endX: 110, + ), + fill: const Color(0xFF336699).withValues(alpha: completionAlpha), + accent: const Color(0xFF995522).withValues(alpha: completionAlpha), + ), + )), + )), + )); + await tester.pumpAndSettle(); + final data = await _pixels(tester, key); + int channel(int x, int y, int component) => + data.getUint8((y * 120 + x) * 4 + component); + expect(channel(10, 4, 3), closeTo(255 * completionAlpha * .5, 2)); + expect(channel(26, 4, 3), closeTo(255 * completionAlpha * .25, 2)); + expect(channel(6, 8, 3), channel(10, 4, 3)); + for (final (x, y) in [(10, 4), (26, 4), (90, 20)]) { + final alpha = channel(x, y, 3) / 255; + expect(channel(x, y, 0), closeTo(0x33 * alpha, 2)); + expect(channel(x, y, 1), closeTo(0x66 * alpha, 2)); + expect(channel(x, y, 2), closeTo(0x99 * alpha, 2)); + } + expect(channel(90, 20, 3), closeTo(255 * completionAlpha, 2)); + // The real interval is solid: no vertical or horizontal fade. + expect(channel(90, 8, 3), channel(100, 25, 3)); + // Both temporal boundaries use the accent, independently of duration. + for (final x in [79, 109]) { + if (!done) { + expect(channel(x, 20, 0), closeTo(0x99, 2)); + expect(channel(x, 20, 1), closeTo(0x55, 2)); + expect(channel(x, 20, 2), closeTo(0x22, 2)); + } else { + // Translucent marker paint composites over the interval/inner border. + expect(channel(x, 20, 0), greaterThan(channel(90, 20, 0))); + } + } + expect(channel(111, 20, 3), 0); + await tester.pumpWidget(const SizedBox()); + }); + } + + for (final exam in [false, true]) { + testWidgets('Event and exam information stays fixed while the span zooms: $exam', ( + tester, + ) async { + final entry = _event(exam: exam); + final app = await _show(tester, [entry]); + final information = find.byKey(ValueKey(('agenda-timeline-bar', entry.id))); + final before = tester.widget( + find.byType(EventTimelineEntry), + ); + expect(tester.getRect(information).width, closeTo(48 * 3.75, .001)); + expect(before.geometry.endX - before.geometry.startX, closeTo(4, .001)); + tester.widget(find.byType(Slider)).onChanged!(24); + await tester.pumpAndSettle(); + final after = tester.widget( + find.byType(EventTimelineEntry), + ); + expect(tester.getRect(information).width, closeTo(48 * 3.75, .001)); + expect(after.geometry.endX - after.geometry.startX, closeTo(96, .001)); + await app.settings.setAgendaLayoutDays(6); + await tester.pumpAndSettle(); + expect(tester.getRect(information).width, closeTo(48 * 5.75, .001)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + } +} From 90f0f699c3ea41c768519f4fd5adc1670dbdc773 Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Wed, 11 Nov 2026 03:19:51 +0800 Subject: [PATCH 06/24] feat(agenda): persist completion and one-shot exam auto completion --- .../kotlin/dev/so1ve/bitree/WidgetAgenda.kt | 28 +- docs/agenda-completion.md | 67 ++ docs/deadline-cutoff.md | 24 +- lib/academics/ui/exams_screen.dart | 2 +- lib/agenda/controller.dart | 201 +++++- lib/agenda/model.dart | 40 +- lib/agenda/ui/agenda_detail.dart | 79 +-- lib/agenda/ui/agenda_editor.dart | 5 + lib/agenda/ui/agenda_list_tile.dart | 55 +- lib/agenda/ui/agenda_operations.dart | 2 +- lib/agenda/ui/agenda_reminder.dart | 2 +- lib/agenda/ui/event_timeline_entry.dart | 2 +- lib/app/controller.dart | 27 +- lib/l10n/formats.dart | 11 +- test/agenda_completion_test.dart | 576 ++++++++++++++++++ test/agenda_detail_test.dart | 66 +- test/agenda_layout_days_test.dart | 9 +- test/agenda_swipe_test.dart | 83 ++- test/calendar_block_test.dart | 14 +- test/settings_about_dialog_test.dart | 6 +- test/support/test_app.dart | 1 + 21 files changed, 1165 insertions(+), 135 deletions(-) create mode 100644 docs/agenda-completion.md create mode 100644 test/agenda_completion_test.dart diff --git a/android/app/src/main/kotlin/dev/so1ve/bitree/WidgetAgenda.kt b/android/app/src/main/kotlin/dev/so1ve/bitree/WidgetAgenda.kt index 6c0fbe1..6c20642 100644 --- a/android/app/src/main/kotlin/dev/so1ve/bitree/WidgetAgenda.kt +++ b/android/app/src/main/kotlin/dev/so1ve/bitree/WidgetAgenda.kt @@ -82,15 +82,24 @@ internal fun readWidgetContent(context: Context): WidgetContent { val todayHeading = "今天${weekdays[weekday(today) - 1]}" try { - val entries = readCourseEntries(context, listOf(today, tomorrow)) + readAgendaEntries(context) + val entries = readCourseEntries(context, listOf(today, tomorrow)) + readAgendaEntries(context, now.timeInMillis) fun entriesOn(date: Calendar) = entries.filter { it.occursOn(date) } .sortedWith(compareBy({ !it.allDay }, { it.startsAt }, { it.kind }, { it.title })) val todayEntries = entriesOn(today) val minute = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE) - val remaining = todayEntries.filter { it.endsAt > now.timeInMillis } + val remaining = todayEntries.filter { + when (it.kind) { + WidgetEntryKind.EVENT, WidgetEntryKind.EXAM -> true + else -> it.endsAt > now.timeInMillis + } + } for (entry in remaining) { - if (entry.endsAt < nextUpdate) nextUpdate = entry.endsAt + // Overdue agenda entries remain visible; past boundaries must never + // create an immediate widget-update loop. Courses keep the old rule. + if (entry.endsAt > now.timeInMillis && entry.endsAt < nextUpdate) { + nextUpdate = entry.endsAt + } } val showTomorrow = minute >= 21 * 60 && remaining.isEmpty() val displayed = if (showTomorrow) entriesOn(tomorrow) else remaining @@ -152,7 +161,7 @@ private fun readCourseEntries(context: Context, dates: List): List { +private fun readAgendaEntries(context: Context, now: Long): List { val file = File(context.filesDir, "agenda.json") if (!file.exists()) return emptyList() @@ -163,7 +172,7 @@ private fun readAgendaEntries(context: Context): List { return (0 until rows.length()).map { rows.getJSONObject(it) } .filter { !it.getBoolean("done") && !it.optBoolean("deleted") } - .map { row -> + .mapNotNull { row -> val kind = WidgetEntryKind.valueOf(row.getString("kind").uppercase(Locale.ROOT)) val start = timestamp(row.getString("startsAt")) val allDay = row.getBoolean("allDay") @@ -172,6 +181,15 @@ private fun readAgendaEntries(context: Context): List { allDay -> dayStart(start).apply { add(Calendar.DATE, 1) }.timeInMillis else -> start } + // Read-only fallback while Flutter is stopped. Only Dart persists + // the one-shot marker; reopening an exam keeps it visible as overdue. + if (kind == WidgetEntryKind.EXAM && + !row.optBoolean("examAutoCompletionHandled") && end <= now + ) return@mapNotNull null + // Match the one-time legacy event migration without writing JSON. + if (kind == WidgetEntryKind.EVENT && + row.optInt("completionSchemaVersion", 0) < 1 && end <= now + ) return@mapNotNull null WidgetEntry( title = row.getString("title"), kind = kind, diff --git a/docs/agenda-completion.md b/docs/agenda-completion.md new file mode 100644 index 0000000..3672055 --- /dev/null +++ b/docs/agenda-completion.md @@ -0,0 +1,67 @@ +# 日程完成与考试自动一次 + +## 完成和时间结束分离 + +`done` 表示真正的完成选择;`isFinished(now)` 保持旧调用接口但统一返回 +`done`,不再用时间经过代替完成。`hasEnded(now)` 单独表示时间边界已到。 + +- DDL 沿用原有完成规则;逾期使用有效截止(含伪提前),仍是严格晚于截止。 +- 仅开始时间的 DDL 可以完成,但没有截止时间,永不逾期。 +- 普通日程不会自动完成;到真实结束时仍未完成即为逾期。 +- 考试到真实结束时自动完成一次。用户手动完成或撤销也会消费这次自动机会, + 因而提前手动完成再撤销也不会在结束时覆盖人工选择。 +- 考试自动完成后撤销,保持未完成并在真实结束后显示逾期;构建、重启、 + 同 ID 同步和编辑不会再次自动完成。 + +伪提前只用于 DDL,不移动普通日程或考试的真实时间。 + +## 持久化与迁移 + +新增 `examAutoCompletionHandled`(默认 false)记忆自动机会是否消费, +`completionSchemaVersion`(当前 1)标识一次性历史迁移。新建日程直接使用 +当前版本;旧 JSON 缺字段时读取为版本 0,解析本身不调用当前时钟。 + +控制器在加载后的可控时钟处迁移并原子保存:旧已结束普通日程标为完成以 +保留原“已结束”历史效果;旧未来普通日程只升级版本,不自动完成。旧已完成 +考试消费自动机会;未处理的到期考试原子写入 `done=true` 和 handled=true。 +版本写回后,撤销不会被下一次加载当作旧数据重新完成。 + +自动与人工操作共用串行队列。人工操作使用 ID 和目标完成值更新最新记录, +避免旧 UI 快照覆盖同步元数据,也不在排队结束时反转最新值。写入失败不 +发布已消费标记,保留重试机会;未处理的考试在补账失败期间不显示逾期。 + +## 时钟与生命周期 + +生产环境由 AppController 激活前台完成时钟,AgendaController 只持有一个 +定时器,指向最近未处理考试的结束时间。暂停/后台取消,恢复前台补账; +关闭 App 后错过的结束时间在下次加载补处理,无需常驻后台任务。失败时 +前台最短 30 秒重试,不能对过去的结束时间建立零延时写入循环。 + +测试可注入时钟。通用 TestAppController 默认关闭后台计时,生产时钟测试 +显式开启并在测试正文结束前销毁,不能依赖晚于 Flutter 校验的 teardown。 + +## 同步、编辑与边界 + +同 ID 学校考试刷新时间、地点等元数据时,保留本地 done、handled 和版本; +单纯完成不把整条记录标为 local,避免阻断之后的远端内容更新。编辑器保留 +这些状态字段。DDL 来源原有的远端提交状态策略不改变。 + +当前学校考试 ID 包含开始时间,远端改开始时间会生成新 ID。在没有可靠 +稳定业务 ID 时不按标题猜配,多场考试不能误合并。因此当前只保证可匹配 +的存续同 ID 记录保留选择;不同 ID 是新记录,物理删除/远端消失后重现也 +没有额外的完成 ledger。扩展跨 ID 保留必须先取得可靠身份和迁移策略。 + +列表、详情、课表角标及提醒使用统一完成/逾期状态;学业考试页“过去考试” +和冲突判断仍使用真实时间是否结束,不能因提前手动完成而隐藏未来冲突。 +非 DDL 删除仍采用原来的物理删除+即时撤销,不扩展 DDL 专用回收站范围。 + +Android 日程小组件只读 agenda.json:未完成逾期日程和已撤销考试不因 +结束时间再次被隐藏;未处理到期考试和旧历史日程可只读派生原兼容状态, +仅 Dart 持久化迁移,不让两个运行时同时重写 JSON。学校课程下课过滤不变, +下一次刷新只选未来边界,避免过去时间造成刷新循环。 + +## 回归 + +覆盖模型/JSON、旧数据迁移一次、考试自动一次及撤销重启、人工/自动排队、 +同 ID 同步、失败重试与退避、生命周期、编辑保留、详情/列表完成撤销和 +非 DDL 删除恢复。无本地 Flutter SDK 时,不将静态审查和推送表述为测试通过。 diff --git a/docs/deadline-cutoff.md b/docs/deadline-cutoff.md index 986ef93..19339ff 100644 --- a/docs/deadline-cutoff.md +++ b/docs/deadline-cutoff.md @@ -3,7 +3,9 @@ ## 时间规则 设置默认关闭,预置每日 22:00–次日 08:00。开关、开始和结束分钟作为一份 -JSON 设置保存;关闭仍保留所选时间。 +JSON 设置保存;关闭仍保留所选时间。时间选择页常驻原生 Material 表盘, +也保留开始/结束的 HH:mm 填写框;表盘可原生切换为键盘模式。公开控件的 +表盘草稿在点击“确定”时保存,取消只恢复当前选择,不退出设置页。 区间两端包含:开始早于结束时匹配同一天;开始晚于结束时匹配跨日区间, 凌晨截止归到前一天的开始时间。开始与结束相同时仅匹配该时间点,不代表 @@ -29,9 +31,11 @@ JSON 设置保存;关闭仍保留所选时间。 命中窗口的 DDL: -- 预留区间为 `[有效截止 − 4 天, 真实截止]`。 -- 左侧主体沿用最小缩放 3.75 天的屏幕定宽规则,不随缩放增长;右边界 - 位于有效截止,受影响条目使用固定 4 天而非自定义拖尾长度。 +- 全局排版宽度 `D` 在设置中配置,默认 4 天、范围 1–30 天;统一覆盖旧的 + 单条拖尾设置,旧 `tailDays` 数据仍保留兼容。 +- 预留区间为 `[有效截止 − D 天, 真实截止]`。 +- 左侧主体为最小缩放 `D − 0.25` 天的屏幕定宽,不随缩放增长(默认 + 3.75 天);右边界位于有效截止。 - 两条标线之间按真实时间间距绘制矩形,宽度跟随时间轴缩放。 - 填充为屏幕定宽的 45° 宽斜纹,同一配色、交替透明度;完成后再衰减。 - 真实标线未完成时使用原标线的 0.5 透明度,完成后再衰减为 0.25。 @@ -40,6 +44,18 @@ JSON 设置保存;关闭仍保留所选时间。 - 延伸段按视口裁剪,不能创建按时间跨度无限扩大的画布;全局泳道高度 规则保持不变。 +## 日程与考试的泳道样式 + +普通日程和考试的左侧信息区同样使用全局 `D` 天预留及 `D − 0.25` 基线天 +的屏幕定宽,整体占位为 `[真实开始 − D 天, 真实结束]`。真实时段由开始、 +结束两条 3px 实线和无渐变填充组成,按实际时距缩放;左侧是同配色的 +45° 半透明斜纹(两带透明度系数 0.5/0.25)及向内绘制的 1.5px 填充色边框。 +完成时在原颜色透明度上再乘 0.5,不能把半透明部分覆盖成固定透明度。 +两条时间线重合时结束线最后绘制。所有区域保持视口裁剪,日期与真实时距 +不因左侧信息区预留而改变。 + +完成、撤销、考试自动一次和旧数据迁移规则见 [日程完成状态](agenda-completion.md)。 + ## 验证 回归测试覆盖时间规则、精确边界、跨日/跨年、单时间点、原始存储不变、 diff --git a/lib/academics/ui/exams_screen.dart b/lib/academics/ui/exams_screen.dart index da1e151..78f0fdb 100644 --- a/lib/academics/ui/exams_screen.dart +++ b/lib/academics/ui/exams_screen.dart @@ -52,7 +52,7 @@ class _ExamsScreenState extends State { .toList(); final exams = selected - .where((entry) => _showPast || !entry.isFinished(now)) + .where((entry) => _showPast || !entry.hasEnded(now)) .toList() ..sort((a, b) => a.startsAt.compareTo(b.startsAt)); final conflicts = findExamConflicts( diff --git a/lib/agenda/controller.dart b/lib/agenda/controller.dart index d6c77fb..72e298e 100644 --- a/lib/agenda/controller.dart +++ b/lib/agenda/controller.dart @@ -13,9 +13,13 @@ import 'physics_lab_deadlines.dart'; import 'store.dart'; class AgendaController extends ChangeNotifier { - AgendaController(this._store); + AgendaController(this._store, {DateTime Function()? now}) + : _now = now ?? campusNow; final AgendaStore _store; + final DateTime Function() _now; + Timer? _completionTimer; + bool _completionClockActive = false; List _entries = const []; final _operations = SerialQueue(); bool _ready = false; @@ -54,18 +58,136 @@ class AgendaController extends ChangeNotifier { _notify(); try { _entries = List.unmodifiable(await _store.read()); + final completion = _completionUpdates(_entries, _now()); + if (completion.changed) { + try { + await _store.write(completion.entries); + _entries = List.unmodifiable(completion.entries); + } on Exception catch (failure, stack) { + // Keep the unmigrated records on failure. A later reconciliation can + // retry, rather than claiming the automatic opportunity was consumed. + AppDiagnostics.instance.report('迁移日程完成状态', failure, stack); + } + } _ready = true; } on Exception catch (failure, stack) { AppDiagnostics.instance.report('读取日程', failure, stack); } finally { _busy = false; _notify(); + _scheduleCompletionClock(); } }); - Future save(AgendaEntry entry) => _write( - (entries) => [...entries.where((item) => item.id != entry.id), entry], - ); + /// Metadata editors opt into retaining the latest completion state when their + /// snapshot was opened before another completion action or clock transition. + /// Ordinary save callers may still intentionally supply completion values. + Future save( + AgendaEntry entry, { + bool preserveCompletion = false, + }) => _write((entries) { + final existing = entries.where((item) => item.id == entry.id).firstOrNull; + final AgendaEntry next; + if (preserveCompletion && existing != null && existing.kind == entry.kind) { + next = entry.copyWith( + done: existing.done, + examAutoCompletionHandled: existing.examAutoCompletionHandled, + completionSchemaVersion: existing.completionSchemaVersion, + ); + } else if (existing != null && + existing.kind == AgendaKind.exam && + entry.kind == AgendaKind.exam && + existing.examAutoCompletionHandled) { + next = entry.copyWith( + examAutoCompletionHandled: true, + // Preserve the existing guard for older callers without the marker. + done: entry.examAutoCompletionHandled ? entry.done : existing.done, + ); + } else { + next = entry; + } + return [...entries.where((item) => item.id != entry.id), next]; + }); + + /// Persist the user's intended value against the latest record, not an old + /// sheet snapshot. Any manual exam choice consumes its automatic opportunity. + Future setDone(String id, bool done) => _operations.run(() async { + if (_disposed || !_ready) return false; + final index = _entries.indexWhere((entry) => entry.id == id && !entry.deleted); + if (index < 0) return false; + final current = _entries[index]; + final handled = current.kind == AgendaKind.exam + ? true + : current.examAutoCompletionHandled; + final version = current.completionSchemaVersion < + AgendaEntry.currentCompletionSchemaVersion + ? AgendaEntry.currentCompletionSchemaVersion + : current.completionSchemaVersion; + if (current.done == done && + current.examAutoCompletionHandled == handled && + current.completionSchemaVersion == version) { + return true; + } + final next = [..._entries]; + next[index] = current.copyWith( + done: done, + examAutoCompletionHandled: handled, + completionSchemaVersion: version, + ); + return _commit(next); + }); + + /// Clock-aware transitions are serialized with edits and syncs. Getters and + /// widget builds remain pure and cannot repeatedly re-complete reopened exams. + Future reconcileExamCompletion({DateTime? now}) => + _operations.run(() async { + if (_disposed || !_ready) return false; + final updates = _completionUpdates(_entries, now ?? _now()); + if (!updates.changed) { + _scheduleCompletionClock(); + return true; + } + return _commit(updates.entries); + }); + + /// The App owns activation (foreground only); this controller owns one timer. + void setCompletionClockActive(bool active) { + if (_disposed) return; + _completionClockActive = active; + _completionTimer?.cancel(); + _completionTimer = null; + if (active) unawaited(reconcileExamCompletion()); + } + + void _scheduleCompletionClock() { + _completionTimer?.cancel(); + _completionTimer = null; + if (_disposed || !_ready || !_completionClockActive) return; + final now = _now(); + final outstanding = _completionUpdates(_entries, now); + DateTime? nextEnd; + for (final entry in _entries) { + if (entry.deleted || + entry.kind != AgendaKind.exam || + entry.examAutoCompletionHandled || + !entry.endsAt!.isAfter(now)) { + continue; + } + if (nextEnd == null || entry.endsAt!.isBefore(nextEnd)) { + nextEnd = entry.endsAt; + } + } + // A failed write leaves its state untouched. Retry while foregrounded, + // without a zero-delay loop that would hammer storage on persistent errors. + final delay = outstanding.changed + ? const Duration(seconds: 30) + : nextEnd?.difference(now); + if (delay == null) return; + _completionTimer = Timer(delay, () { + _completionTimer = null; + unawaited(reconcileExamCompletion()); + }); + } Future restore(AgendaEntry entry) => restoreEntries([entry]); @@ -111,17 +233,29 @@ class AgendaController extends ChangeNotifier { List exams, { void Function()? checkActive, }) => _write((entries) { - final edited = { + final savedById = { for (final entry in entries) - if (entry.kind == AgendaKind.exam && entry.term == term && entry.local) - entry.id, + if (entry.kind == AgendaKind.exam && entry.term == term) entry.id: entry, + }; + final edited = { + for (final entry in savedById.values) + if (entry.local) entry.id, }; return [ ...entries.where( (entry) => entry.kind != AgendaKind.exam || entry.term != term || entry.local, ), - ...exams.where((entry) => !edited.contains(entry.id)), + for (final entry in exams) + if (!edited.contains(entry.id)) + if (savedById[entry.id] case final saved?) + entry.copyWith( + done: saved.done, + examAutoCompletionHandled: saved.examAutoCompletionHandled, + completionSchemaVersion: saved.completionSchemaVersion, + ) + else + entry, ]; }, checkActive: checkActive); @@ -233,6 +367,7 @@ class AgendaController extends ChangeNotifier { List? Function(List entries) transform, { void Function()? checkActive, }) => _operations.run(() async { + if (_disposed) return false; if (!_ready) { _reportNotReady(); return false; @@ -251,8 +386,9 @@ class AgendaController extends ChangeNotifier { _notify(); try { checkActive?.call(); - await _store.write(next); - _entries = List.unmodifiable(next); + final prepared = _completionUpdates(next, _now()).entries; + await _store.write(prepared); + _entries = List.unmodifiable(prepared); return true; } on RequestCancelled { rethrow; @@ -262,6 +398,7 @@ class AgendaController extends ChangeNotifier { } finally { _busy = false; _notify(); + _scheduleCompletionClock(); } } @@ -278,10 +415,54 @@ class AgendaController extends ChangeNotifier { @override void dispose() { _disposed = true; + _completionTimer?.cancel(); + _completionTimer = null; super.dispose(); } } +({List entries, bool changed}) _completionUpdates( + List entries, + DateTime now, +) { + var changed = false; + final next = []; + for (final entry in entries) { + var done = entry.done; + var handled = entry.examAutoCompletionHandled; + var version = entry.completionSchemaVersion; + if (version < AgendaEntry.currentCompletionSchemaVersion) { + // Old events were presented as finished solely because time elapsed. + // Preserve that history exactly once, never again after an explicit undo. + if (entry.kind == AgendaKind.event && !entry.deleted && entry.hasEnded(now)) { + done = true; + } + version = AgendaEntry.currentCompletionSchemaVersion; + } + if (entry.kind == AgendaKind.exam && done) handled = true; + if (entry.kind == AgendaKind.exam && + !entry.deleted && + !handled && + entry.hasEnded(now)) { + done = true; + handled = true; + } + if (done != entry.done || + handled != entry.examAutoCompletionHandled || + version != entry.completionSchemaVersion) { + changed = true; + next.add(entry.copyWith( + done: done, + examAutoCompletionHandled: handled, + completionSchemaVersion: version, + )); + } else { + next.add(entry); + } + } + return (entries: next, changed: changed); +} + bool _sameRemoteEntry(AgendaEntry left, AgendaEntry right) { if (left.id == right.id && left.source == right.source) return true; if (left.kind != AgendaKind.deadline || diff --git a/lib/agenda/model.dart b/lib/agenda/model.dart index 001f4e8..461ed47 100644 --- a/lib/agenda/model.dart +++ b/lib/agenda/model.dart @@ -21,6 +21,8 @@ class AgendaEntry { this.location = '', this.notes = '', this.done = false, + this.examAutoCompletionHandled = false, + this.completionSchemaVersion = currentCompletionSchemaVersion, this.deleted = false, this.term, this.local = true, @@ -70,6 +72,7 @@ class AgendaEntry { } } + static const currentCompletionSchemaVersion = 1; static const defaultTailDays = 4; static const maxTailDays = 30; @@ -82,6 +85,14 @@ class AgendaEntry { final String location; final String notes; final bool done; + + /// True after an exam's one automatic opportunity or a manual completion + /// choice. Undoing completion never resets this durable marker. + final bool examAutoCompletionHandled; + + /// Zero means an old record still needs its one-time, clock-aware migration. + /// Parsing JSON itself deliberately has no dependence on the current time. + final int completionSchemaVersion; final bool deleted; final String? term; final bool local; @@ -132,10 +143,21 @@ class AgendaEntry { ? this : copyWith(deadlineCutoffWindow: window); - bool overdue(DateTime now) => - isDeadline && !startOnly && !done && dueAt.isBefore(now); + bool overdue(DateTime now) { + if (done || (isDeadline && startOnly)) return false; + // An unhandled exam is awaiting its one automatic completion, not overdue. + // Only an explicitly reopened exam may become overdue after its real end. + if (kind == AgendaKind.exam && !examAutoCompletionHandled) return false; + return isDeadline ? dueAt.isBefore(now) : hasEnded(now); + } + + /// Time passing and completion are separate. In particular, reopening an + /// ended exam must not make it completed again during a widget rebuild. + bool hasEnded(DateTime now) => isDeadline + ? !startOnly && !dueAt.isAfter(now) + : !endsAt!.isAfter(now); - bool isFinished(DateTime now) => isDeadline ? done : !endsAt!.isAfter(now); + bool isFinished(DateTime now) => done; bool occursOn(DateTime date) { final day = calendarDate(date); @@ -146,6 +168,8 @@ class AgendaEntry { AgendaEntry copyWith({ bool? done, + bool? examAutoCompletionHandled, + int? completionSchemaVersion, bool? deleted, DeadlineCutoffWindow? deadlineCutoffWindow, }) => AgendaEntry( @@ -158,6 +182,10 @@ class AgendaEntry { location: location, notes: notes, done: done ?? this.done, + examAutoCompletionHandled: + examAutoCompletionHandled ?? this.examAutoCompletionHandled, + completionSchemaVersion: + completionSchemaVersion ?? this.completionSchemaVersion, deleted: deleted ?? this.deleted, term: term, local: local, @@ -180,6 +208,8 @@ class AgendaEntry { 'location': location, 'notes': notes, 'done': done, + 'examAutoCompletionHandled': examAutoCompletionHandled, + 'completionSchemaVersion': completionSchemaVersion, 'deleted': deleted, 'term': term, 'local': local, @@ -220,6 +250,10 @@ class AgendaEntry { location: jsonStringField(json, 'location'), notes: jsonStringField(json, 'notes'), done: jsonBoolField(json, 'done'), + examAutoCompletionHandled: + jsonBoolField(json, 'examAutoCompletionHandled', fallback: false), + completionSchemaVersion: + jsonIntFieldOrNull(json, 'completionSchemaVersion') ?? 0, deleted: jsonBoolField(json, 'deleted', fallback: false), term: jsonStringFieldOrNull(json, 'term'), local: jsonBoolField(json, 'local'), diff --git a/lib/agenda/ui/agenda_detail.dart b/lib/agenda/ui/agenda_detail.dart index acb8a14..5f0a127 100644 --- a/lib/agenda/ui/agenda_detail.dart +++ b/lib/agenda/ui/agenda_detail.dart @@ -36,8 +36,6 @@ Future showAgendaEntry( ? l10n.completed : overdue ? l10n.overdue - : current.isFinished(now) - ? l10n.ended : null; final notes = localizedAgendaNotes(l10n, current); final statusColor = overdue ? scheme.error : scheme.onSurfaceVariant; @@ -190,50 +188,39 @@ Future showAgendaEntry( ], ), ), - actions: current.isDeadline - ? SheetActionBar.split( - leading: TextButton.icon( - onPressed: controller.agenda.busy ? null : edit, - icon: const Icon(Icons.edit_outlined, size: 18), - label: Text(l10n.edit), - ), - primary: FilledButton.icon( - style: current.done - ? FilledButton.styleFrom( - backgroundColor: scheme.secondaryContainer, - foregroundColor: scheme.onSecondaryContainer, - ) - : null, - onPressed: controller.agenda.busy - ? null - : () async { - final saved = await toggleAgendaDone( - controller.agenda, - current, - ); - if (!saved && context.mounted) { - showAppNotice( - context, - context.l10n.operationSaveFailed, - ); - } - }, - icon: Icon( - current.done ? Icons.undo_rounded : Icons.check_rounded, - size: 20, - ), - label: Text( - current.done ? l10n.agendaMarkUndone : l10n.agendaMarkDone, - ), - ), - ) - : SheetActionBar.primary( - primary: FilledButton.icon( - onPressed: controller.agenda.busy ? null : edit, - icon: const Icon(Icons.edit_outlined, size: 18), - label: Text(l10n.edit), - ), - ), + actions: SheetActionBar.split( + leading: TextButton.icon( + onPressed: controller.agenda.busy ? null : edit, + icon: const Icon(Icons.edit_outlined, size: 18), + label: Text(l10n.edit), + ), + primary: FilledButton.icon( + style: current.done + ? FilledButton.styleFrom( + backgroundColor: scheme.secondaryContainer, + foregroundColor: scheme.onSecondaryContainer, + ) + : null, + onPressed: controller.agenda.busy + ? null + : () async { + final saved = await toggleAgendaDone( + controller.agenda, + current, + ); + if (!saved && context.mounted) { + showAppNotice(context, context.l10n.operationSaveFailed); + } + }, + icon: Icon( + current.done ? Icons.undo_rounded : Icons.check_rounded, + size: 20, + ), + label: Text( + current.done ? l10n.agendaMarkUndone : l10n.agendaMarkDone, + ), + ), + ), ); }, ), diff --git a/lib/agenda/ui/agenda_editor.dart b/lib/agenda/ui/agenda_editor.dart index 4355e46..ec2c250 100644 --- a/lib/agenda/ui/agenda_editor.dart +++ b/lib/agenda/ui/agenda_editor.dart @@ -171,6 +171,10 @@ class _AgendaEditorState extends State<_AgendaEditor> { location: _location.text.trim(), notes: _notes.text.trim(), done: widget.entry?.done ?? false, + examAutoCompletionHandled: + widget.entry?.examAutoCompletionHandled ?? false, + completionSchemaVersion: widget.entry?.completionSchemaVersion ?? + AgendaEntry.currentCompletionSchemaVersion, term: widget.entry?.term ?? widget.term, source: widget.entry?.source, course: widget.entry?.course ?? '', @@ -185,6 +189,7 @@ class _AgendaEditorState extends State<_AgendaEditor> { _kind == AgendaKind.deadline && (widget.entry?.startOnly ?? false), ), + preserveCompletion: true, ); if (!mounted) return; if (success) { diff --git a/lib/agenda/ui/agenda_list_tile.dart b/lib/agenda/ui/agenda_list_tile.dart index 4c1d58b..95c0d8b 100644 --- a/lib/agenda/ui/agenda_list_tile.dart +++ b/lib/agenda/ui/agenda_list_tile.dart @@ -9,7 +9,6 @@ import '../../ui/swipe_action_tile.dart'; import '../model.dart'; import 'agenda_detail.dart'; import 'agenda_operations.dart'; -import 'calendar_list.dart'; import 'completion_text.dart'; class AgendaListTile extends StatelessWidget { @@ -82,20 +81,16 @@ class AgendaListTile extends StatelessWidget { onInvoke: () => deleteAgendaEntry(context, controller, entry, confirm: false), ), - rightAction: entry.isDeadline - ? TileSwipeAction( - label: entry.done - ? l10n.agendaSwipeRestore - : l10n.agendaSwipeDone, - releaseLabel: entry.done - ? l10n.agendaSwipeRestoreRelease - : l10n.agendaSwipeDoneRelease, - icon: entry.done ? Icons.undo_rounded : Icons.check_rounded, - background: scheme.primaryContainer, - foreground: scheme.onPrimaryContainer, - onInvoke: () => _complete(context), - ) - : null, + rightAction: TileSwipeAction( + label: entry.done ? l10n.agendaSwipeRestore : l10n.agendaSwipeDone, + releaseLabel: entry.done + ? l10n.agendaSwipeRestoreRelease + : l10n.agendaSwipeDoneRelease, + icon: entry.done ? Icons.undo_rounded : Icons.check_rounded, + background: scheme.primaryContainer, + foreground: scheme.onPrimaryContainer, + onInvoke: () => _complete(context), + ), child: Material( type: MaterialType.transparency, child: InkWell( @@ -110,24 +105,18 @@ class AgendaListTile extends StatelessWidget { children: [ SizedBox.square( dimension: 44, - child: entry.isDeadline - ? Tooltip( - message: entry.done - ? l10n.agendaMarkUndone - : l10n.agendaMarkDone, - child: Checkbox( - value: entry.done, - shape: const CircleBorder(), - onChanged: controller.agenda.busy - ? null - : (_) => _complete(context, haptic: true), - ), - ) - : Icon( - agendaIcon(entry.kind), - size: 22, - color: scheme.onSurfaceVariant, - ), + child: Tooltip( + message: entry.done + ? l10n.agendaMarkUndone + : l10n.agendaMarkDone, + child: Checkbox( + value: entry.done, + shape: const CircleBorder(), + onChanged: controller.agenda.busy + ? null + : (_) => _complete(context, haptic: true), + ), + ), ), const SizedBox(width: 4), Expanded( diff --git a/lib/agenda/ui/agenda_operations.dart b/lib/agenda/ui/agenda_operations.dart index bdff87f..31a53d3 100644 --- a/lib/agenda/ui/agenda_operations.dart +++ b/lib/agenda/ui/agenda_operations.dart @@ -14,7 +14,7 @@ Future toggleAgendaDone( AgendaEntry entry, { bool haptic = true, }) async { - final saved = await controller.save(entry.copyWith(done: !entry.done)); + final saved = await controller.setDone(entry.id, !entry.done); if (saved && haptic) { HapticFeedback.selectionClick(); } diff --git a/lib/agenda/ui/agenda_reminder.dart b/lib/agenda/ui/agenda_reminder.dart index 5cbb4a4..7cf0b54 100644 --- a/lib/agenda/ui/agenda_reminder.dart +++ b/lib/agenda/ui/agenda_reminder.dart @@ -116,7 +116,7 @@ class _AgendaReminderState extends State final caption = entry == null ? '' : entry.overdue(widget.now) - ? l10n.agendaReminderOverdue(localizedAgendaClock(l10n, entry)) + ? l10n.agendaReminderOverdue(localizedAgendaOverdueClock(l10n, entry)) : entry.isDeadline || entry.allDay ? localizedAgendaTime(l10n, entry) : entry.startsAt.isAfter(widget.now) diff --git a/lib/agenda/ui/event_timeline_entry.dart b/lib/agenda/ui/event_timeline_entry.dart index db4c3f7..ab00ff1 100644 --- a/lib/agenda/ui/event_timeline_entry.dart +++ b/lib/agenda/ui/event_timeline_entry.dart @@ -123,7 +123,7 @@ class EventTimelineEntry extends StatelessWidget { else if (overdue) l10n.agendaTimelineStatusOverdue, ].join('\n'); final caption = overdue - ? l10n.agendaReminderOverdue(clockText(entry.endsAt!)) + ? l10n.agendaReminderOverdue(localizedAgendaOverdueClock(l10n, entry)) : _eventCaption(l10n, entry); final semantics = l10n.agendaTimelineSemanticsEntry( entry.kind.localizedLabel(l10n), diff --git a/lib/app/controller.dart b/lib/app/controller.dart index 0a80fe2..f727628 100644 --- a/lib/app/controller.dart +++ b/lib/app/controller.dart @@ -1,4 +1,4 @@ -import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../academics/controller.dart'; @@ -18,7 +18,7 @@ import 'settings_controller.dart'; /// Composes feature state and owns application startup. /// Feature changes are observed by their views, not relayed through this object. -class AppController extends ChangeNotifier { +class AppController extends ChangeNotifier with WidgetsBindingObserver { AppController({ required ScheduleStore store, required SchoolService school, @@ -29,6 +29,7 @@ class AppController extends ChangeNotifier { SystemPaletteSource? systemPaletteSource, Future> Function()? coursePaletteLoader, MoocStorage? moocStorage, + this.manageAgendaCompletionClock = true, }) : settings = AppSettingsController( preferences, systemPaletteSource: systemPaletteSource, @@ -55,6 +56,18 @@ class AppController extends ChangeNotifier { ); settings.deadlineCutoff.addListener(_updateDeadlineCutoff); _updateDeadlineCutoff(); + if (manageAgendaCompletionClock) { + WidgetsBinding.instance.addObserver(this); + } + } + + final bool manageAgendaCompletionClock; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (manageAgendaCompletionClock && _ready) { + agenda.setCompletionClockActive(state == AppLifecycleState.resumed); + } } void _updateDeadlineCutoff() => @@ -80,11 +93,21 @@ class AppController extends ChangeNotifier { await mooc.load(); await academics.load(school.username); _ready = true; + if (manageAgendaCompletionClock) { + final lifecycle = WidgetsBinding.instance.lifecycleState; + agenda.setCompletionClockActive( + lifecycle == null || lifecycle == AppLifecycleState.resumed, + ); + } notifyListeners(); } @override void dispose() { + if (manageAgendaCompletionClock) { + WidgetsBinding.instance.removeObserver(this); + } + agenda.setCompletionClockActive(false); settings.deadlineCutoff.removeListener(_updateDeadlineCutoff); settings.dispose(); sync.dispose(); diff --git a/lib/l10n/formats.dart b/lib/l10n/formats.dart index ce54ee2..12bdc55 100644 --- a/lib/l10n/formats.dart +++ b/lib/l10n/formats.dart @@ -263,19 +263,24 @@ String localizedCalendarItemTitle(AppLocalizations l10n, CalendarItem item) => String localizedAgendaClock(AppLocalizations l10n, AgendaEntry entry) => entry.effectiveAllDay ? l10n.allDay : clockText(entry.scheduledAt); +String localizedAgendaOverdueClock(AppLocalizations l10n, AgendaEntry entry) => + entry.effectiveAllDay + ? l10n.allDay + : clockText(entry.isDeadline ? entry.dueAt : entry.endsAt!); + String localizedAgendaTimeCaption( AppLocalizations l10n, AgendaEntry entry, DateTime now, ) { + if (entry.done) return l10n.completed; + if (entry.overdue(now)) return l10n.overdue; if (entry.isDeadline) { - if (entry.done) return l10n.completed; // Overdue is impossible without a due date, and calling the opening time // a due date would be wrong. if (entry.startOnly) return l10n.start; - return entry.overdue(now) ? l10n.overdue : l10n.due; + return l10n.due; } - if (entry.isFinished(now)) return l10n.ended; if (entry.allDay) return ''; final end = entry.endsAt!; final endTime = calendarDate(entry.startsAt) == calendarDate(end) diff --git a/test/agenda_completion_test.dart b/test/agenda_completion_test.dart new file mode 100644 index 0000000..8807986 --- /dev/null +++ b/test/agenda_completion_test.dart @@ -0,0 +1,576 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:bitree/academics/controller.dart'; +import 'package:bitree/agenda/controller.dart'; +import 'package:bitree/agenda/deadline_source.dart'; +import 'package:bitree/agenda/model.dart'; +import 'package:bitree/agenda/ui/agenda_list_tile.dart'; +import 'package:bitree/agenda/ui/agenda_operations.dart'; +import 'package:bitree/app/controller.dart'; +import 'package:bitree/l10n/app_localizations_zh.dart'; +import 'package:bitree/l10n/formats.dart'; +import 'package:bitree/school/service.dart'; +import 'package:bitree/ui/swipe_action_tile.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(2030, 1, 2, 12); + +AgendaEntry _item({ + String id = 'item', + AgendaKind kind = AgendaKind.event, + DateTime? startsAt, + DateTime? endsAt, + bool done = false, + bool handled = false, + bool local = true, + String title = '日程', + String location = '原地点', +}) => AgendaEntry( + id: id, + title: title, + kind: kind, + startsAt: startsAt ?? _now.subtract(const Duration(hours: 2)), + endsAt: kind == AgendaKind.deadline + ? null + : endsAt ?? _now.subtract(const Duration(hours: 1)), + done: done, + examAutoCompletionHandled: handled, + local: local, + term: 'term', + location: location, +); + +AgendaEntry _legacy(AgendaEntry entry) { + final json = entry.toJson() + ..remove('completionSchemaVersion') + ..remove('examAutoCompletionHandled'); + return AgendaEntry.fromJson(json); +} + +class _Store extends MemoryAgendaStore { + _Store(super.entries); + + int writes = 0; + bool fail = false; + Completer? started; + Completer? release; + + @override + Future write(List entries) async { + writes++; + final waiting = release; + if (waiting != null) { + if (started?.isCompleted == false) started!.complete(); + await waiting.future; + } + if (fail) throw Exception('simulated agenda write failure'); + await super.write(entries); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Elapsed time and completion are independent for all kinds', () { + for (final kind in AgendaKind.values) { + final entry = _item(kind: kind, handled: kind == AgendaKind.exam); + expect(entry.done, isFalse); + expect(entry.isFinished(_now), isFalse); + expect(entry.hasEnded(_now), isTrue); + expect(entry.overdue(_now), isTrue); + final complete = entry.copyWith(done: true); + expect(complete.isFinished(_now), isTrue); + expect(complete.overdue(_now), isFalse); + } + final awaitingAutomatic = _item(kind: AgendaKind.exam); + expect(awaitingAutomatic.hasEnded(_now), isTrue); + expect(awaitingAutomatic.overdue(_now), isFalse); + final future = _item(endsAt: _now.add(const Duration(hours: 1))); + expect(future.hasEnded(_now), isFalse); + expect(future.overdue(_now), isFalse); + expect(future.copyWith(done: true).isFinished(_now), isTrue); + expect(future.copyWith(done: true).hasEnded(_now), isFalse); + final open = AgendaEntry( + id: 'open', title: '开放测验', kind: AgendaKind.deadline, + startsAt: _now.subtract(const Duration(days: 30)), startOnly: true, + ); + expect(open.hasEnded(_now), isFalse); + expect(open.overdue(_now), isFalse); + }); + + test('Non-DDL overdue uses the end and never the cutoff window or start', () { + const cutoff = DeadlineCutoffWindow(enabled: true); + for (final kind in [AgendaKind.event, AgendaKind.exam]) { + final entry = _item( + kind: kind, + handled: kind == AgendaKind.exam, + startsAt: _now.subtract(const Duration(hours: 1)), + endsAt: _now.add(const Duration(hours: 1)), + ).withDeadlineCutoff(cutoff); + expect(entry.hasDeadlineCutoff, isFalse); + expect(entry.overdue(_now), isFalse); + expect(entry.overdue(entry.endsAt!), isTrue); + } + final ddl = _item(kind: AgendaKind.deadline); + expect(ddl.overdue(ddl.dueAt), isFalse); + }); + + test('Completion metadata round-trips and missing schema means legacy only', () { + final entry = _item(kind: AgendaKind.exam).copyWith( + done: false, examAutoCompletionHandled: true, + ); + final restored = AgendaEntry.fromJson( + jsonDecode(jsonEncode(entry.toJson())) as Map, + ); + expect(restored.examAutoCompletionHandled, isTrue); + expect(restored.completionSchemaVersion, AgendaEntry.currentCompletionSchemaVersion); + expect(restored.done, isFalse); + expect(restored.copyWith(deleted: true).examAutoCompletionHandled, isTrue); + final legacy = _legacy(entry); + expect(legacy.done, isFalse); + expect(legacy.completionSchemaVersion, 0); + expect(legacy.examAutoCompletionHandled, isFalse); + expect(_item().completionSchemaVersion, AgendaEntry.currentCompletionSchemaVersion); + }); + + test('Old ended events migrate once, while new past events remain overdue', () async { + final store = _Store([ + _legacy(_item(id: 'legacy')), + _item(id: 'new'), + _legacy(_item(id: 'future', endsAt: _now.add(const Duration(hours: 1)))), + ]); + final controller = AgendaController(store, now: () => _now); + addTearDown(controller.dispose); + await controller.load(); + expect(controller.entries.singleWhere((e) => e.id == 'legacy').done, isTrue); + expect(controller.entries.singleWhere((e) => e.id == 'new').done, isFalse); + expect(controller.entries.singleWhere((e) => e.id == 'future').done, isFalse); + expect(store.writes, 1); + expect(await controller.setDone('legacy', false), isTrue); + expect(controller.entries.singleWhere((e) => e.id == 'legacy').overdue(_now), isTrue); + await controller.reconcileExamCompletion(now: _now.add(const Duration(days: 1))); + expect(controller.entries.every((e) => !e.done), isTrue); + final reopened = AgendaController(store, now: () => _now.add(const Duration(days: 2))); + addTearDown(reopened.dispose); + await reopened.load(); + expect(reopened.entries.every((e) => !e.done), isTrue); + expect(store.entries.every((e) => e.completionSchemaVersion == 1), isTrue); + }); + + test('Legacy exam completion is retained and ended exams get one opportunity', () async { + final store = _Store([ + _legacy(_item(id: 'past', kind: AgendaKind.exam)), + _legacy(_item( + id: 'manual', kind: AgendaKind.exam, done: true, + endsAt: _now.add(const Duration(hours: 1)), + )), + ]); + final controller = AgendaController(store, now: () => _now); + addTearDown(controller.dispose); + await controller.load(); + expect(controller.entries.every((e) => e.done && e.examAutoCompletionHandled), isTrue); + expect(store.writes, 1); + await controller.reconcileExamCompletion(); + await controller.reconcileExamCompletion(); + expect(store.writes, 1); + expect(await controller.setDone('past', false), isTrue); + await controller.reconcileExamCompletion(); + final restarted = AgendaController(store, now: () => _now.add(const Duration(days: 2))); + addTearDown(restarted.dispose); + await restarted.load(); + final past = restarted.entries.singleWhere((e) => e.id == 'past'); + expect(past.done, isFalse); + expect(past.examAutoCompletionHandled, isTrue); + expect(past.overdue(_now), isTrue); + }); + + test('Manual choices before the end consume the future automatic opportunity', () async { + var clock = _now; + final store = _Store([_item( + kind: AgendaKind.exam, + endsAt: _now.add(const Duration(hours: 1)), + )]); + final controller = AgendaController(store, now: () => clock); + addTearDown(controller.dispose); + await controller.load(); + expect(await controller.setDone('item', true), isTrue); + expect(await controller.setDone('item', false), isTrue); + clock = _now.add(const Duration(hours: 2)); + await controller.reconcileExamCompletion(); + expect(controller.entries.single.done, isFalse); + expect(controller.entries.single.examAutoCompletionHandled, isTrue); + expect(controller.entries.single.overdue(clock), isTrue); + expect(store.writes, 2); + }); + + test('Failed migration or auto-completion does not consume state', () async { + final store = _Store([_legacy(_item(kind: AgendaKind.exam))])..fail = true; + final controller = AgendaController(store, now: () => _now); + addTearDown(controller.dispose); + await controller.load(); + expect(controller.ready, isTrue); + expect(controller.entries.single.done, isFalse); + expect(controller.entries.single.examAutoCompletionHandled, isFalse); + expect(controller.entries.single.completionSchemaVersion, 0); + expect(store.entries.single.done, isFalse); + expect(controller.entries.single.overdue(_now), isFalse); + expect(await controller.reconcileExamCompletion(), isFalse); + store.fail = false; + expect(await controller.reconcileExamCompletion(), isTrue); + expect(controller.entries.single.done, isTrue); + expect(controller.entries.single.examAutoCompletionHandled, isTrue); + final writes = store.writes; + await controller.reconcileExamCompletion(); + expect(store.writes, writes); + }); + + test('Manual write failure leaves completion untouched and can be retried', () async { + final store = _Store([_item(kind: AgendaKind.exam)]); + final controller = AgendaController(store, now: () => _now); + addTearDown(controller.dispose); + await controller.load(); + store.fail = true; + expect(await controller.setDone('item', false), isFalse); + expect(controller.entries.single.done, isTrue); + store.fail = false; + expect(await controller.setDone('item', false), isTrue); + expect(controller.entries.single.done, isFalse); + expect(controller.entries.single.examAutoCompletionHandled, isTrue); + }); + + test('A queued completion intent cannot be reversed by an automatic transition', () async { + var clock = _now; + final store = _Store([_item( + kind: AgendaKind.exam, + endsAt: _now.add(const Duration(hours: 1)), + )]); + final controller = AgendaController(store, now: () => clock); + addTearDown(controller.dispose); + await controller.load(); + final snapshot = controller.entries.single; + store.started = Completer(); + store.release = Completer(); + clock = _now.add(const Duration(hours: 2)); + final automatic = controller.reconcileExamCompletion(); + await store.started!.future; + final manual = toggleAgendaDone(controller, snapshot, haptic: false); + store.release!.complete(); + expect(await automatic, isTrue); + expect(await manual, isTrue); + expect(controller.entries.single.done, isTrue); + expect(store.writes, 1); + }); + + test('Completion actions resolve current metadata rather than stale snapshots', () async { + final store = _Store([_item(kind: AgendaKind.exam, endsAt: _now.add(const Duration(hours: 1)))]); + final controller = AgendaController(store, now: () => _now); + addTearDown(controller.dispose); + await controller.load(); + final snapshot = controller.entries.single; + await controller.save(_item( + kind: AgendaKind.exam, endsAt: _now.add(const Duration(hours: 2)), + title: '更新的标题', location: '新地点', + )); + await toggleAgendaDone(controller, snapshot, haptic: false); + expect(controller.entries.single.title, '更新的标题'); + expect(controller.entries.single.location, '新地点'); + expect(controller.entries.single.endsAt, _now.add(const Duration(hours: 2))); + expect(controller.entries.single.done, isTrue); + expect(controller.entries.single.local, isTrue); + }); + + for (final kind in [AgendaKind.event, AgendaKind.exam]) { + test('Metadata edits retain newer completion choices for $kind', () async { + for (final target in [false, true]) { + final store = _Store([_item( + kind: kind, done: !target, handled: true, + )]); + final controller = AgendaController(store, now: () => _now); + addTearDown(controller.dispose); + await controller.load(); + final snapshot = controller.entries.single; + expect(snapshot.examAutoCompletionHandled, isTrue); + await controller.setDone('item', target); + final edited = _item( + kind: kind, done: snapshot.done, handled: true, + title: '编辑后的标题', location: '编辑后的地点', + ); + expect(await controller.save(edited, preserveCompletion: true), isTrue); + final saved = controller.entries.single; + expect(saved.done, target); + expect(saved.examAutoCompletionHandled, isTrue); + expect(saved.completionSchemaVersion, + AgendaEntry.currentCompletionSchemaVersion); + expect(saved.title, '编辑后的标题'); + expect(saved.location, '编辑后的地点'); + } + }); + + test('Queued metadata edits read completion at execution time for $kind', () async { + final store = _Store([_item(kind: kind, handled: true)]); + final controller = AgendaController(store, now: () => _now); + addTearDown(controller.dispose); + await controller.load(); + final snapshot = controller.entries.single; + store.started = Completer(); + store.release = Completer(); + final completing = controller.setDone('item', true); + await store.started!.future; + final editing = controller.save(_item( + kind: kind, done: snapshot.done, handled: true, + title: '排队中的编辑', location: '最新编辑地点', + ), preserveCompletion: true); + store.release!.complete(); + expect(await completing, isTrue); + expect(await editing, isTrue); + expect(controller.entries.single.done, isTrue); + expect(controller.entries.single.examAutoCompletionHandled, isTrue); + expect(controller.entries.single.title, '排队中的编辑'); + expect(controller.entries.single.location, '最新编辑地点'); + }); + + test('Default save can still explicitly change completion for $kind', () async { + final store = _Store([_item(kind: kind, handled: true)]); + final controller = AgendaController(store, now: () => _now); + addTearDown(controller.dispose); + await controller.load(); + expect(await controller.save(controller.entries.single.copyWith(done: true)), isTrue); + expect(controller.entries.single.done, isTrue); + }); + } + + test('Metadata edits cannot restore legacy migration over a newer undo', () async { + final store = _Store([_item(done: true)]); + final controller = AgendaController(store, now: () => _now); + addTearDown(controller.dispose); + await controller.load(); + final snapshot = controller.entries.single; + await controller.setDone('item', false); + final oldEditor = snapshot.copyWith(completionSchemaVersion: 0); + expect(await controller.save(oldEditor, preserveCompletion: true), isTrue); + expect(controller.entries.single.done, isFalse); + expect(controller.entries.single.completionSchemaVersion, + AgendaEntry.currentCompletionSchemaVersion); + await controller.load(); + expect(controller.entries.single.done, isFalse); + }); + + test('Same-id sync preserves completion but refreshes remote exam content', () async { + var clock = _now; + final store = _Store([_item( + kind: AgendaKind.exam, local: false, + endsAt: _now.add(const Duration(hours: 1)), + )]); + final controller = AgendaController(store, now: () => clock); + addTearDown(controller.dispose); + await controller.load(); + await controller.setDone('item', true); + await controller.setDone('item', false); + await controller.replaceExams('term', [_item( + kind: AgendaKind.exam, local: false, title: '远端新标题', location: '新考场', + endsAt: _now.add(const Duration(hours: 2)), + )]); + final current = controller.entries.single; + expect(current.done, isFalse); + expect(current.examAutoCompletionHandled, isTrue); + expect(current.local, isFalse); + expect(current.title, '远端新标题'); + expect(current.location, '新考场'); + clock = _now.add(const Duration(hours: 3)); + await controller.reconcileExamCompletion(); + expect(controller.entries.single.done, isFalse); + // Saving an older editor snapshot without the marker cannot reset it. + await controller.save(_item(kind: AgendaKind.exam, local: false)); + expect(controller.entries.single.done, isFalse); + expect(controller.entries.single.examAutoCompletionHandled, isTrue); + await controller.load(); + expect(controller.entries.single.done, isFalse); + }); + + test('Local content protection and distinct exam identities are not conflated', () async { + final store = _Store([_item(kind: AgendaKind.exam, handled: true)]); + final controller = AgendaController(store, now: () => _now); + addTearDown(controller.dispose); + await controller.load(); + await controller.replaceExams('term', [_item( + kind: AgendaKind.exam, local: false, title: '应被本地内容保护挡住', + )]); + expect(controller.entries.single.title, '日程'); + expect(controller.entries.single.local, isTrue); + final remoteStore = _Store([_item(kind: AgendaKind.exam, local: false, handled: true)]); + final remote = AgendaController(remoteStore, now: () => _now); + addTearDown(remote.dispose); + await remote.load(); + await remote.replaceExams('term', [_item( + id: 'new-id', kind: AgendaKind.exam, local: false, + endsAt: _now.add(const Duration(hours: 1)), + )]); + expect(remote.entries.single.id, 'new-id'); + expect(remote.entries.single.examAutoCompletionHandled, isFalse); + }); + + test('Immediate deletion undo preserves a reopened exam opportunity marker', () async { + final store = _Store([_item(kind: AgendaKind.exam)]); + final controller = AgendaController(store, now: () => _now); + addTearDown(controller.dispose); + await controller.load(); + await controller.setDone('item', false); + final reopened = controller.entries.single; + expect(await controller.delete(reopened), isTrue); + expect(controller.entries, isEmpty); + expect(controller.deletedDeadlines, isEmpty); + expect(await controller.restore(reopened), isTrue); + await controller.reconcileExamCompletion(); + expect(controller.entries.single.done, isFalse); + expect(controller.entries.single.examAutoCompletionHandled, isTrue); + expect(controller.entries.single.overdue(_now), isTrue); + }); + + test('DDL synchronization still owns remote submission completion', () async { + final raw = AgendaEntry( + id: 'ddl', title: '平台作业', kind: AgendaKind.deadline, + startsAt: _now, source: DeadlineSource.eclass, local: false, + ); + final store = _Store([raw]); + final controller = AgendaController(store, now: () => _now); + addTearDown(controller.dispose); + await controller.load(); + await controller.setDone('ddl', true); + expect(controller.entries.single.done, isTrue); + await controller.mergeDeadlines([raw]); + expect(controller.entries.single.done, isFalse); + expect(controller.entries.single.examAutoCompletionHandled, isFalse); + }); + + testWidgets('The foreground clock completes once at the nearest exam end', (tester) async { + var clock = _now; + final end = _now.add(const Duration(minutes: 1)); + final store = _Store([_item(kind: AgendaKind.exam, endsAt: end)]); + final controller = AgendaController(store, now: () => clock); + try { + await controller.load(); + controller.setCompletionClockActive(true); + await tester.pump(); + expect(controller.entries.single.done, isFalse); + clock = end; + await tester.pump(const Duration(minutes: 1)); + expect(controller.entries.single.done, isTrue); + expect(controller.entries.single.examAutoCompletionHandled, isTrue); + expect(store.writes, 1); + await controller.setDone('item', false); + clock = end.add(const Duration(days: 1)); + await tester.pump(const Duration(days: 1)); + expect(controller.entries.single.done, isFalse); + expect(store.writes, 2); + } finally { + controller.dispose(); + } + }); + + testWidgets('Failed automatic writes retry with backoff, never a busy loop', (tester) async { + var clock = _now; + final end = _now.add(const Duration(minutes: 1)); + final store = _Store([_item(kind: AgendaKind.exam, endsAt: end)]); + final controller = AgendaController(store, now: () => clock); + try { + await controller.load(); + controller.setCompletionClockActive(true); + await tester.pump(); + store.fail = true; + clock = end; + await tester.pump(const Duration(minutes: 1)); + expect(store.writes, 1); + expect(controller.entries.single.examAutoCompletionHandled, isFalse); + expect(controller.entries.single.overdue(clock), isFalse); + clock = end.add(const Duration(seconds: 29)); + await tester.pump(const Duration(seconds: 29)); + expect(store.writes, 1); + store.fail = false; + clock = end.add(const Duration(seconds: 30)); + await tester.pump(const Duration(seconds: 1)); + expect(store.writes, 2); + expect(controller.entries.single.done, isTrue); + expect(controller.entries.single.examAutoCompletionHandled, isTrue); + } finally { + controller.dispose(); + } + }); + + testWidgets('Production app lifecycle pauses, resumes and disposes its clock', (tester) async { + var clock = _now; + final store = _Store([_item( + kind: AgendaKind.exam, endsAt: _now.add(const Duration(minutes: 1)), + )]); + final agenda = AgendaController(store, now: () => clock); + final app = AppController( + store: MemoryScheduleStore(), school: SchoolService(), + schoolStorage: MemorySchoolStorage(), preferences: MemoryPreferences(), + agenda: agenda, academics: AcademicController(MemoryAcademicStore()), + moocStorage: MemoryMoocStorage(), coursePaletteLoader: () async => {}, + ); + try { + expect(app.manageAgendaCompletionClock, isTrue); + await app.initialize(); + await tester.pump(); + app.didChangeAppLifecycleState(AppLifecycleState.paused); + clock = _now.add(const Duration(minutes: 2)); + await tester.pump(const Duration(minutes: 2)); + expect(agenda.entries.single.done, isFalse); + app.didChangeAppLifecycleState(AppLifecycleState.resumed); + await tester.pump(); + expect(agenda.entries.single.done, isTrue); + await agenda.setDone('item', false); + app.didChangeAppLifecycleState(AppLifecycleState.paused); + app.didChangeAppLifecycleState(AppLifecycleState.resumed); + await tester.pump(); + expect(agenda.entries.single.done, isFalse); + } finally { + app.dispose(); + } + await tester.pump(const Duration(days: 1)); + expect(store.writes, 2); + expect(tester.takeException(), isNull); + }); + + for (final kind in [AgendaKind.event, AgendaKind.exam]) { + testWidgets('$kind list and detail both expose reversible completion', (tester) async { + final raw = _item(kind: kind, handled: true); + final agenda = AgendaController(_Store([raw]), now: () => _now); + final app = TestAppController(agenda: agenda); + await app.initialize(); + await tester.pumpWidget(MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold(body: ListenableBuilder( + listenable: agenda, + builder: (context, _) => AgendaListTile( + controller: app, entry: agenda.entries.single, now: _now, + ), + )), + )); + await tester.pumpAndSettle(); + expect(find.byType(Checkbox), findsOneWidget); + final tile = tester.widget(find.byType(SwipeActionTile)); + expect(tile.rightAction, isNotNull); + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + expect(agenda.entries.single.done, isTrue); + final l10n = AppLocalizationsZh(); + expect(localizedAgendaTimeCaption(l10n, agenda.entries.single, _now), l10n.completed); + await tester.tap(find.text(raw.title)); + await tester.pumpAndSettle(); + expect(find.widgetWithText(FilledButton, l10n.agendaMarkUndone), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, l10n.agendaMarkUndone)); + await tester.pumpAndSettle(); + expect(agenda.entries.single.done, isFalse); + expect(agenda.entries.single.overdue(_now), isTrue); + expect(find.widgetWithText(FilledButton, l10n.agendaMarkDone), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + } +} diff --git a/test/agenda_detail_test.dart b/test/agenda_detail_test.dart index 1010925..9ee0ab0 100644 --- a/test/agenda_detail_test.dart +++ b/test/agenda_detail_test.dart @@ -170,6 +170,51 @@ void main() { }, ); + for (final kind in [AgendaKind.event, AgendaKind.exam]) { + testWidgets('A past $kind completes and reopens in the same detail sheet', ( + tester, + ) async { + final entry = AgendaEntry( + id: 'past-${kind.name}', + title: '过期待办', + kind: kind, + startsAt: DateTime.utc(2020, 10, 27, 9), + endsAt: DateTime.utc(2020, 10, 27, 11), + done: false, + // This exam already used its automatic completion opportunity; opening + // or editing its sheet must not consume the user's explicit undo. + examAutoCompletionHandled: kind == AgendaKind.exam, + completionSchemaVersion: AgendaEntry.currentCompletionSchemaVersion, + ); + final controller = await open(tester, entry); + final sheet = tester.element(find.byType(SheetLayout)); + expect(find.text('已逾期'), findsOneWidget); + expect(find.widgetWithText(TextButton, '编辑'), findsOneWidget); + expect(find.widgetWithText(FilledButton, '标记完成'), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, '标记完成')); + await tester.pumpAndSettle(); + expect(tester.element(find.byType(SheetLayout)), same(sheet)); + expect(controller.agenda.entries.single.done, isTrue); + expect(find.text('已完成'), findsOneWidget); + expect(find.text('已逾期'), findsNothing); + expect( + tester.widget(find.byType(CompletionText)).completed, + isTrue, + ); + await tester.tap(find.widgetWithText(FilledButton, '标为未完成')); + await tester.pumpAndSettle(); + expect(tester.element(find.byType(SheetLayout)), same(sheet)); + final reopened = controller.agenda.entries.single; + expect(reopened.done, isFalse); + expect(reopened.overdue(DateTime.utc(2030)), isTrue); + if (kind == AgendaKind.exam) { + expect(reopened.examAutoCompletionHandled, isTrue); + } + expect(find.text('已逾期'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + testWidgets('Delete still asks for confirmation and closes after deletion', ( tester, ) async { @@ -262,11 +307,13 @@ void main() { endsAt: DateTime.utc(2026, 12, 28, 11), location: '文萃楼F704', notes: '座位号:12', + done: true, + examAutoCompletionHandled: true, term: '2026-2027-1', local: false, ); final controller = await open(tester, entry); - await tester.tap(find.widgetWithText(FilledButton, '编辑')); + await tester.tap(find.widgetWithText(TextButton, '编辑')); await tester.pumpAndSettle(); expect(find.text('编辑考试'), findsOneWidget); expect(find.text('拖尾长度(天)'), findsNothing); @@ -286,6 +333,9 @@ void main() { expect(saved.location, entry.location); expect(saved.notes, entry.notes); expect(saved.term, entry.term); + expect(saved.done, isTrue); + expect(saved.examAutoCompletionHandled, isTrue); + expect(saved.completionSchemaVersion, entry.completionSchemaVersion); expect(saved.local, isTrue); expect(find.byType(SheetLayout), findsNothing); expect(tester.takeException(), isNull); @@ -303,6 +353,8 @@ void main() { location: '文萃楼F704', term: '2026-2027-1', local: false, + done: false, + examAutoCompletionHandled: true, details: const ExamAgendaDetails( seat: '12', mode: '闭卷', @@ -310,7 +362,7 @@ void main() { ), ); final controller = await open(tester, entry); - await tester.tap(find.widgetWithText(FilledButton, '编辑')); + await tester.tap(find.widgetWithText(TextButton, '编辑')); await tester.pumpAndSettle(); final notes = find.byWidgetPredicate( (widget) => @@ -325,6 +377,9 @@ void main() { final saved = controller.agenda.entries.single; expect(saved.details, isNull); expect(saved.notes, '座位号:12\n闭卷\n教师:张老师'); + expect(saved.done, isFalse); + expect(saved.examAutoCompletionHandled, isTrue); + expect(saved.completionSchemaVersion, entry.completionSchemaVersion); expect(tester.takeException(), isNull); }, ); @@ -347,6 +402,8 @@ void main() { startsAt: start, endsAt: end, allDay: true, + done: false, + completionSchemaVersion: AgendaEntry.currentCompletionSchemaVersion, ), ); expect( @@ -357,8 +414,9 @@ void main() { findsOneWidget, ); expect(find.text('全天', findRichText: true), findsOneWidget); - expect(find.text('已结束'), findsOneWidget); - expect(find.text('标记完成'), findsNothing); + expect(find.text('已结束'), findsNothing); + expect(find.text('已逾期'), findsOneWidget); + expect(find.widgetWithText(FilledButton, '标记完成'), findsOneWidget); expect(tester.takeException(), isNull); }, ); diff --git a/test/agenda_layout_days_test.dart b/test/agenda_layout_days_test.dart index e3672cb..a4f2e01 100644 --- a/test/agenda_layout_days_test.dart +++ b/test/agenda_layout_days_test.dart @@ -255,7 +255,10 @@ void main() { )); await tester.pumpAndSettle(); final field = find.byKey(const ValueKey('agenda-layout-days-input')); - await tester.ensureVisible(field); + await tester.scrollUntilVisible( + field, 200, scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); await tester.enterText(field, '7'); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); @@ -272,7 +275,9 @@ void main() { final textField = tester.widget(field); expect(textField.controller!.text, '12'); final slider = find.byKey(const ValueKey('agenda-layout-days-slider')); - await tester.ensureVisible(slider); + await tester.scrollUntilVisible( + slider, 200, scrollable: find.byType(Scrollable).first, + ); await tester.pumpAndSettle(); tester.widget(slider).onChanged!(30); await tester.pumpAndSettle(); diff --git a/test/agenda_swipe_test.dart b/test/agenda_swipe_test.dart index 5e067bc..c19a091 100644 --- a/test/agenda_swipe_test.dart +++ b/test/agenda_swipe_test.dart @@ -369,21 +369,74 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('A schedule has only a left swipe delete action', (tester) async { - final app = await showAgenda(tester, kind: AgendaKind.event); - final row = find.byKey(const ValueKey('task')); - await swipeRow(tester, row, const Offset(250, 0)); - await tester.pumpAndSettle(); - expect(app.agenda.entries.single.kind, AgendaKind.event); - expect(app.agenda.entries.single.done, isFalse); - expect(find.text('完成'), findsNothing); - await swipeRow(tester, row, const Offset(-90, 0)); - await tester.pumpAndSettle(); - await tester.tap(find.text('删除')); - await tester.pumpAndSettle(); - expect(app.agenda.entries, isEmpty); - expect(tester.takeException(), isNull); - }); + for (final kind in [AgendaKind.event, AgendaKind.exam]) { + testWidgets('$kind completes and restores by swipe, and still deletes left', ( + tester, + ) async { + final app = await showAgenda(tester, kind: kind); + var row = find.byKey(const ValueKey('task')).hitTestable(); + expect( + find.descendant(of: row, matching: find.byType(Checkbox)), + findsOneWidget, + ); + await swipeRow(tester, row, const Offset(250, 0)); + await tester.pumpAndSettle(); + expect(app.agenda.entries.single.kind, kind); + expect(app.agenda.entries.single.done, isTrue); + if (kind == AgendaKind.exam) { + expect(app.agenda.entries.single.examAutoCompletionHandled, isTrue); + } + await tester.tap(find.text('已完成 1')); + await tester.pumpAndSettle(); + row = find.byKey(const ValueKey('task')).hitTestable(); + expect(row, findsOneWidget); + await swipeRow(tester, row, const Offset(250, 0)); + await tester.pumpAndSettle(); + expect(app.agenda.entries.single.done, isFalse); + if (kind == AgendaKind.exam) { + expect(app.agenda.entries.single.examAutoCompletionHandled, isTrue); + } + await tester.tap(find.text('未完成 1')); + await tester.pumpAndSettle(); + row = find.byKey(const ValueKey('task')).hitTestable(); + expect(row, findsOneWidget); + await swipeRow(tester, row, const Offset(-90, 0)); + await tester.pumpAndSettle(); + await tester.tap(find.text('删除')); + await tester.pumpAndSettle(); + expect(app.agenda.entries, isEmpty); + // The DDL recycle bin remains DDL-only despite sharing completion UI. + expect(app.agenda.deletedDeadlines, isEmpty); + expect(tester.takeException(), isNull); + }); + } + + for (final kind in [AgendaKind.event, AgendaKind.exam]) { + testWidgets('$kind completion checkbox works in both status lists', ( + tester, + ) async { + final app = await showAgenda(tester, kind: kind); + Finder checkbox() => find.descendant( + of: find.byKey(const ValueKey('task')).hitTestable(), + matching: find.byType(Checkbox), + ); + expect(tester.widget(checkbox()).value, isFalse); + await tester.tap(checkbox()); + await tester.pumpAndSettle(); + expect(app.agenda.entries.single.done, isTrue); + await tester.tap(find.text('已完成 1')); + await tester.pumpAndSettle(); + expect(tester.widget(checkbox()).value, isTrue); + await tester.tap(checkbox()); + await tester.pumpAndSettle(); + expect(app.agenda.entries.single.done, isFalse); + expect(find.text('未完成 1'), findsOneWidget); + if (kind == AgendaKind.exam) { + expect(app.agenda.entries.single.examAutoCompletionHandled, isTrue); + } + expect(tester.takeException(), isNull); + }); + } testWidgets('Swiping away deletion feedback keeps the deadline deleted', ( tester, diff --git a/test/calendar_block_test.dart b/test/calendar_block_test.dart index de5bda1..054067e 100644 --- a/test/calendar_block_test.dart +++ b/test/calendar_block_test.dart @@ -136,13 +136,21 @@ void main() { final context = tester.element(find.byType(CalendarBlockTile)); final timeline = agendaTimelineColors(context, event, startsAt); expect(timeline, (fill: fill, text: text, accent: accent)); + final afterEnd = startsAt.add(const Duration(hours: 2)); + // A new unfinished event becomes overdue, not automatically done. + expect(event.isFinished(afterEnd), isFalse); + expect(event.overdue(afterEnd), isTrue); + expect( + agendaTimelineColors(context, event, afterEnd).fill, + scheme.errorContainer, + ); if (cat != null) { final finished = agendaTimelineColors( context, - event, - startsAt.add(const Duration(hours: 2)), + event.copyWith(done: true), + afterEnd, ); - expect(finished.fill, fill.withValues(alpha: 0.55)); + expect(finished.fill, fill.withValues(alpha: fill.a * 0.5)); final deadline = AgendaEntry( id: 'manual-ddl', title: '自建 DDL', diff --git a/test/settings_about_dialog_test.dart b/test/settings_about_dialog_test.dart index dfaa7a4..75fe203 100644 --- a/test/settings_about_dialog_test.dart +++ b/test/settings_about_dialog_test.dart @@ -56,8 +56,12 @@ void main() { ), ); + await tester.pumpAndSettle(); final about = find.text('关于 BITree'); - await tester.ensureVisible(about); + await tester.scrollUntilVisible( + about, 200, scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); await tester.tap(about); await tester.pumpAndSettle(); diff --git a/test/support/test_app.dart b/test/support/test_app.dart index a89382f..932ef7c 100644 --- a/test/support/test_app.dart +++ b/test/support/test_app.dart @@ -136,6 +136,7 @@ class TestAppController extends AppController { AcademicStore? academicStore, super.systemPaletteSource, }) : super( + manageAgendaCompletionClock: false, coursePaletteLoader: () async => testPalettes(), store: scheduleStore ?? From ae2db6b1581a69f4e0d7184c23b8f1af76cb664f Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Wed, 11 Nov 2026 03:26:45 +0800 Subject: [PATCH 07/24] fix(academics): keep exam time filters independent of completion --- lib/academics/ui/exams_screen.dart | 4 +- test/exam_completion_filter_test.dart | 86 +++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 test/exam_completion_filter_test.dart diff --git a/lib/academics/ui/exams_screen.dart b/lib/academics/ui/exams_screen.dart index 78f0fdb..bf31d56 100644 --- a/lib/academics/ui/exams_screen.dart +++ b/lib/academics/ui/exams_screen.dart @@ -156,7 +156,7 @@ class _ExamsScreenState extends State { term: term, ), ), - if (selected.any((exam) => exam.isFinished(now))) + if (selected.any((exam) => exam.hasEnded(now))) TextButton( onPressed: () => setState(() => _showPast = !_showPast), child: Text( @@ -243,7 +243,7 @@ class _ExamConflictsScreen extends StatelessWidget { (entry) => entry.kind == AgendaKind.exam && (term == null || entry.term == term) && - (showPast || !entry.isFinished(now)), + (showPast || !entry.hasEnded(now)), ), controller.timetable.schedules, includeClasses: includeClasses, diff --git a/test/exam_completion_filter_test.dart b/test/exam_completion_filter_test.dart new file mode 100644 index 0000000..7eb7417 --- /dev/null +++ b/test/exam_completion_filter_test.dart @@ -0,0 +1,86 @@ +import 'package:bitree/academics/ui/exams_screen.dart'; +import 'package:bitree/agenda/model.dart'; +import 'package:bitree/l10n/app_localizations_zh.dart'; +import 'package:bitree/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/test_app.dart'; + +AgendaEntry _exam( + String id, + DateTime start, { + bool done = false, + bool handled = false, +}) => AgendaEntry( + id: id, + title: id, + kind: AgendaKind.exam, + startsAt: start, + endsAt: start.add(const Duration(hours: 2)), + done: done, + examAutoCompletionHandled: handled, +); + +Future _show(WidgetTester tester, List entries) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final app = TestAppController(entries: entries); + await app.initialize(); + await tester.pumpWidget(MaterialApp( + theme: bitreeTheme(Brightness.light), + home: ExamsScreen(controller: app), + )); + await tester.pumpAndSettle(); +} + +void main() { + final l10n = AppLocalizationsZh(); + + testWidgets('Manual completion cannot hide a future exam or its conflicts', ( + tester, + ) async { + await _show(tester, [ + _exam('未来已完成考试', DateTime.utc(2099, 1, 2, 9), + done: true, handled: true), + _exam('未来待考考试', DateTime.utc(2099, 1, 2, 10)), + ]); + expect(find.text('未来已完成考试'), findsOneWidget); + expect(find.text('未来待考考试'), findsOneWidget); + expect(find.text(l10n.showFinishedExams), findsNothing); + final conflicts = find.text(l10n.timeConflictsFound(1)); + await tester.scrollUntilVisible( + conflicts, 160, scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await tester.tap(conflicts); + await tester.pumpAndSettle(); + expect(find.text(l10n.noTimeConflict), findsNothing); + expect(find.textContaining('未来已完成考试'), findsWidgets); + expect(find.textContaining('未来待考考试'), findsWidgets); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('A reopened past exam remains accessible through the past filter', ( + tester, + ) async { + await _show(tester, [ + _exam('已撤销完成的过去考试', DateTime.utc(2020, 1, 2, 9), handled: true), + ]); + expect(find.text('已撤销完成的过去考试'), findsNothing); + final showPast = find.text(l10n.showFinishedExams); + await tester.scrollUntilVisible( + showPast, 160, scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await tester.tap(showPast); + await tester.pumpAndSettle(); + expect(find.text('已撤销完成的过去考试'), findsOneWidget); + expect(find.text(l10n.hideFinishedExams), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); +} From eac5320683dc7dcb8f5587f85afde1d92fff5119 Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Wed, 11 Nov 2026 03:33:10 +0800 Subject: [PATCH 08/24] fix(test): isolate completion diagnostics and update state expectations --- test/agenda_completion_test.dart | 25 ++++++++++++++++++++++++- test/deadline_cutoff_test.dart | 4 +++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/test/agenda_completion_test.dart b/test/agenda_completion_test.dart index 8807986..6e29d22 100644 --- a/test/agenda_completion_test.dart +++ b/test/agenda_completion_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:bitree/academics/controller.dart'; import 'package:bitree/agenda/controller.dart'; @@ -8,12 +9,14 @@ import 'package:bitree/agenda/model.dart'; import 'package:bitree/agenda/ui/agenda_list_tile.dart'; import 'package:bitree/agenda/ui/agenda_operations.dart'; import 'package:bitree/app/controller.dart'; +import 'package:bitree/core/diagnostics.dart'; import 'package:bitree/l10n/app_localizations_zh.dart'; import 'package:bitree/l10n/formats.dart'; import 'package:bitree/school/service.dart'; import 'package:bitree/ui/swipe_action_tile.dart'; import 'package:bitree/ui/theme.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'support/test_app.dart'; @@ -75,6 +78,24 @@ class _Store extends MemoryAgendaStore { void main() { TestWidgetsFlutterBinding.ensureInitialized(); + const pathProvider = MethodChannel('plugins.flutter.io/path_provider'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + late Directory logDirectory; + setUpAll(() async { + logDirectory = await Directory.systemTemp.createTemp( + 'bitree-agenda-completion-logs-', + ); + messenger.setMockMethodCallHandler(pathProvider, (_) async => logDirectory.path); + // Prime the shared successful directory future before fault-injection tests. + await AppDiagnostics.instance.recentLines(); + }); + tearDownAll(() async { + await AppDiagnostics.instance.recentLines(); + messenger.setMockMethodCallHandler(pathProvider, null); + expect(logDirectory.absolute.path, contains('bitree-agenda-completion-logs-')); + await logDirectory.delete(recursive: true); + }); test('Elapsed time and completion are independent for all kinds', () { for (final kind in AgendaKind.values) { @@ -561,7 +582,9 @@ void main() { expect(agenda.entries.single.done, isTrue); final l10n = AppLocalizationsZh(); expect(localizedAgendaTimeCaption(l10n, agenda.entries.single, _now), l10n.completed); - await tester.tap(find.text(raw.title)); + // CompletionText paints separate normal/strike-through text layers. + // Activate the row rather than ambiguously matching both title widgets. + await tester.tap(find.byType(AgendaListTile)); await tester.pumpAndSettle(); expect(find.widgetWithText(FilledButton, l10n.agendaMarkUndone), findsOneWidget); await tester.tap(find.widgetWithText(FilledButton, l10n.agendaMarkUndone)); diff --git a/test/deadline_cutoff_test.dart b/test/deadline_cutoff_test.dart index 569f00a..3fe1fad 100644 --- a/test/deadline_cutoff_test.dart +++ b/test/deadline_cutoff_test.dart @@ -332,7 +332,9 @@ void main() { expect(shown.scheduledAt, raw.startsAt); expect(shown.hasDeadlineCutoff, isFalse); expect(shown.hasAdjustedDeadline, isFalse); - expect(shown.overdue(DateTime.utc(2030)), isFalse); + final later = DateTime.utc(2030); + expect(shown.overdue(later), raw.overdue(later)); + expect(shown.overdue(later), raw.kind == AgendaKind.event); } }); }); From 9248f2e1493346f41b0d373cb32538fce0232ad2 Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Wed, 11 Nov 2026 03:52:08 +0800 Subject: [PATCH 09/24] fix(test): keep completion diagnostics IO inside the test clock --- test/agenda_completion_test.dart | 28 +++++---- test/support/memory_diagnostics_io.dart | 79 +++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 test/support/memory_diagnostics_io.dart diff --git a/test/agenda_completion_test.dart b/test/agenda_completion_test.dart index 6e29d22..f7e74a2 100644 --- a/test/agenda_completion_test.dart +++ b/test/agenda_completion_test.dart @@ -19,6 +19,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'support/memory_diagnostics_io.dart'; import 'support/test_app.dart'; final _now = DateTime.utc(2030, 1, 2, 12); @@ -81,20 +82,23 @@ void main() { const pathProvider = MethodChannel('plugins.flutter.io/path_provider'); final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; - late Directory logDirectory; + IOOverrides? previousIo; setUpAll(() async { - logDirectory = await Directory.systemTemp.createTemp( - 'bitree-agenda-completion-logs-', - ); - messenger.setMockMethodCallHandler(pathProvider, (_) async => logDirectory.path); - // Prime the shared successful directory future before fault-injection tests. + final support = '${Directory.systemTemp.path}/bitree-completion-memory'; + previousIo = IOOverrides.current; + // Install before priming the singleton: its cached Directory must also be + // in memory, not a real Directory with IO tied to a retired FakeAsync zone. + IOOverrides.global = MemoryDiagnosticsIo(); + messenger.setMockMethodCallHandler(pathProvider, (_) async => support); await AppDiagnostics.instance.recentLines(); }); tearDownAll(() async { - await AppDiagnostics.instance.recentLines(); - messenger.setMockMethodCallHandler(pathProvider, null); - expect(logDirectory.absolute.path, contains('bitree-agenda-completion-logs-')); - await logDirectory.delete(recursive: true); + try { + await AppDiagnostics.instance.recentLines(); + } finally { + messenger.setMockMethodCallHandler(pathProvider, null); + IOOverrides.global = previousIo; + } }); test('Elapsed time and completion are independent for all kinds', () { @@ -240,6 +244,10 @@ void main() { expect(controller.entries.single.completionSchemaVersion, 0); expect(store.entries.single.done, isFalse); expect(controller.entries.single.overdue(_now), isFalse); + expect( + await AppDiagnostics.instance.recentLines(), + contains('simulated agenda write failure'), + ); expect(await controller.reconcileExamCompletion(), isFalse); store.fail = false; expect(await controller.reconcileExamCompletion(), isTrue); diff --git a/test/support/memory_diagnostics_io.dart b/test/support/memory_diagnostics_io.dart new file mode 100644 index 0000000..66cafe5 --- /dev/null +++ b/test/support/memory_diagnostics_io.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// A test-only file system for diagnostics. Completion clock tests run in a +/// FakeAsync zone: real disk IO queued there can outlive its retired zone and +/// strand the shared log queue. Immediate memory futures keep failures visible +/// without making the state-machine tests depend on native disk/plugin work. +class MemoryDiagnosticsIo extends IOOverrides { + final _files = >{}; + + @override + Directory createDirectory(String path) => _Directory(this, path); + + @override + File createFile(String path) => _File(this, path); +} + +class _Directory extends Fake implements Directory { + _Directory(this.owner, this.path); + + final MemoryDiagnosticsIo owner; + @override + final String path; + + @override + Future create({bool recursive = false}) async => this; + + @override + Stream list({ + bool recursive = false, + bool followLinks = true, + }) => Stream.fromIterable([ + for (final file in owner._files.keys.toList()) + if (file.startsWith('$path/')) _File(owner, file), + ]); +} + +class _File extends Fake implements File { + _File(this.owner, this.path); + + final MemoryDiagnosticsIo owner; + @override + final String path; + @override + Uri get uri => Uri.file(path); + + @override + Future writeAsString( + String contents, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }) async { + final bytes = encoding.encode(contents); + owner._files[path] = [ + if (mode == FileMode.append) ...(owner._files[path] ?? []), + ...bytes, + ]; + return this; + } + + @override + Stream> openRead([int? start, int? end]) => + Stream>.value( + List.of(owner._files[path] ?? []).sublist(start ?? 0, end), + ); + + @override + Future readAsString({Encoding encoding = utf8}) async => + encoding.decode(owner._files[path] ?? []); + + @override + Future delete({bool recursive = false}) async { + owner._files.remove(path); + return this; + } +} From 7b0cbc431af6440da5e3ab372b80ca2d402820b9 Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Wed, 11 Nov 2026 04:00:43 +0800 Subject: [PATCH 10/24] fix(test): declare diagnostics IO override final --- test/support/memory_diagnostics_io.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/support/memory_diagnostics_io.dart b/test/support/memory_diagnostics_io.dart index 66cafe5..ccbdc3c 100644 --- a/test/support/memory_diagnostics_io.dart +++ b/test/support/memory_diagnostics_io.dart @@ -7,7 +7,7 @@ import 'package:flutter_test/flutter_test.dart'; /// FakeAsync zone: real disk IO queued there can outlive its retired zone and /// strand the shared log queue. Immediate memory futures keep failures visible /// without making the state-machine tests depend on native disk/plugin work. -class MemoryDiagnosticsIo extends IOOverrides { +final class MemoryDiagnosticsIo extends IOOverrides { final _files = >{}; @override From 4acce20e23eafcf133daaafced150348ddfcf106 Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Wed, 11 Nov 2026 04:16:41 +0800 Subject: [PATCH 11/24] refactor(agenda): inject completion error reporting for deterministic tests --- lib/agenda/controller.dart | 15 +++-- test/agenda_completion_test.dart | 87 +++++++++++-------------- test/support/memory_diagnostics_io.dart | 79 ---------------------- 3 files changed, 48 insertions(+), 133 deletions(-) delete mode 100644 test/support/memory_diagnostics_io.dart diff --git a/lib/agenda/controller.dart b/lib/agenda/controller.dart index 72e298e..75086df 100644 --- a/lib/agenda/controller.dart +++ b/lib/agenda/controller.dart @@ -13,11 +13,16 @@ import 'physics_lab_deadlines.dart'; import 'store.dart'; class AgendaController extends ChangeNotifier { - AgendaController(this._store, {DateTime Function()? now}) - : _now = now ?? campusNow; + AgendaController( + this._store, { + DateTime Function()? now, + void Function(String, Object, StackTrace)? reportError, + }) : _now = now ?? campusNow, + _reportError = reportError ?? AppDiagnostics.instance.report; final AgendaStore _store; final DateTime Function() _now; + final void Function(String, Object, StackTrace) _reportError; Timer? _completionTimer; bool _completionClockActive = false; List _entries = const []; @@ -66,12 +71,12 @@ class AgendaController extends ChangeNotifier { } on Exception catch (failure, stack) { // Keep the unmigrated records on failure. A later reconciliation can // retry, rather than claiming the automatic opportunity was consumed. - AppDiagnostics.instance.report('迁移日程完成状态', failure, stack); + _reportError('迁移日程完成状态', failure, stack); } } _ready = true; } on Exception catch (failure, stack) { - AppDiagnostics.instance.report('读取日程', failure, stack); + _reportError('读取日程', failure, stack); } finally { _busy = false; _notify(); @@ -393,7 +398,7 @@ class AgendaController extends ChangeNotifier { } on RequestCancelled { rethrow; } on Exception catch (failure, stack) { - AppDiagnostics.instance.report('保存日程', failure, stack); + _reportError('保存日程', failure, stack); return false; } finally { _busy = false; diff --git a/test/agenda_completion_test.dart b/test/agenda_completion_test.dart index f7e74a2..283f48d 100644 --- a/test/agenda_completion_test.dart +++ b/test/agenda_completion_test.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:bitree/academics/controller.dart'; import 'package:bitree/agenda/controller.dart'; @@ -9,17 +8,14 @@ import 'package:bitree/agenda/model.dart'; import 'package:bitree/agenda/ui/agenda_list_tile.dart'; import 'package:bitree/agenda/ui/agenda_operations.dart'; import 'package:bitree/app/controller.dart'; -import 'package:bitree/core/diagnostics.dart'; import 'package:bitree/l10n/app_localizations_zh.dart'; import 'package:bitree/l10n/formats.dart'; import 'package:bitree/school/service.dart'; import 'package:bitree/ui/swipe_action_tile.dart'; import 'package:bitree/ui/theme.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'support/memory_diagnostics_io.dart'; import 'support/test_app.dart'; final _now = DateTime.utc(2030, 1, 2, 12); @@ -77,29 +73,20 @@ class _Store extends MemoryAgendaStore { } } +final _reportedErrors = []; + +AgendaController _controller(_Store store, {DateTime Function()? now}) => + AgendaController( + store, + now: now, + reportError: (operation, failure, _) { + _reportedErrors.add('$operation: $failure'); + }, + ); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); - const pathProvider = MethodChannel('plugins.flutter.io/path_provider'); - final messenger = - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; - IOOverrides? previousIo; - setUpAll(() async { - final support = '${Directory.systemTemp.path}/bitree-completion-memory'; - previousIo = IOOverrides.current; - // Install before priming the singleton: its cached Directory must also be - // in memory, not a real Directory with IO tied to a retired FakeAsync zone. - IOOverrides.global = MemoryDiagnosticsIo(); - messenger.setMockMethodCallHandler(pathProvider, (_) async => support); - await AppDiagnostics.instance.recentLines(); - }); - tearDownAll(() async { - try { - await AppDiagnostics.instance.recentLines(); - } finally { - messenger.setMockMethodCallHandler(pathProvider, null); - IOOverrides.global = previousIo; - } - }); + setUp(_reportedErrors.clear); test('Elapsed time and completion are independent for all kinds', () { for (final kind in AgendaKind.values) { @@ -169,7 +156,7 @@ void main() { _item(id: 'new'), _legacy(_item(id: 'future', endsAt: _now.add(const Duration(hours: 1)))), ]); - final controller = AgendaController(store, now: () => _now); + final controller = _controller(store, now: () => _now); addTearDown(controller.dispose); await controller.load(); expect(controller.entries.singleWhere((e) => e.id == 'legacy').done, isTrue); @@ -180,7 +167,7 @@ void main() { expect(controller.entries.singleWhere((e) => e.id == 'legacy').overdue(_now), isTrue); await controller.reconcileExamCompletion(now: _now.add(const Duration(days: 1))); expect(controller.entries.every((e) => !e.done), isTrue); - final reopened = AgendaController(store, now: () => _now.add(const Duration(days: 2))); + final reopened = _controller(store, now: () => _now.add(const Duration(days: 2))); addTearDown(reopened.dispose); await reopened.load(); expect(reopened.entries.every((e) => !e.done), isTrue); @@ -195,7 +182,7 @@ void main() { endsAt: _now.add(const Duration(hours: 1)), )), ]); - final controller = AgendaController(store, now: () => _now); + final controller = _controller(store, now: () => _now); addTearDown(controller.dispose); await controller.load(); expect(controller.entries.every((e) => e.done && e.examAutoCompletionHandled), isTrue); @@ -205,7 +192,7 @@ void main() { expect(store.writes, 1); expect(await controller.setDone('past', false), isTrue); await controller.reconcileExamCompletion(); - final restarted = AgendaController(store, now: () => _now.add(const Duration(days: 2))); + final restarted = _controller(store, now: () => _now.add(const Duration(days: 2))); addTearDown(restarted.dispose); await restarted.load(); final past = restarted.entries.singleWhere((e) => e.id == 'past'); @@ -220,7 +207,7 @@ void main() { kind: AgendaKind.exam, endsAt: _now.add(const Duration(hours: 1)), )]); - final controller = AgendaController(store, now: () => clock); + final controller = _controller(store, now: () => clock); addTearDown(controller.dispose); await controller.load(); expect(await controller.setDone('item', true), isTrue); @@ -235,7 +222,7 @@ void main() { test('Failed migration or auto-completion does not consume state', () async { final store = _Store([_legacy(_item(kind: AgendaKind.exam))])..fail = true; - final controller = AgendaController(store, now: () => _now); + final controller = _controller(store, now: () => _now); addTearDown(controller.dispose); await controller.load(); expect(controller.ready, isTrue); @@ -245,8 +232,8 @@ void main() { expect(store.entries.single.done, isFalse); expect(controller.entries.single.overdue(_now), isFalse); expect( - await AppDiagnostics.instance.recentLines(), - contains('simulated agenda write failure'), + _reportedErrors, + contains(contains('simulated agenda write failure')), ); expect(await controller.reconcileExamCompletion(), isFalse); store.fail = false; @@ -260,7 +247,7 @@ void main() { test('Manual write failure leaves completion untouched and can be retried', () async { final store = _Store([_item(kind: AgendaKind.exam)]); - final controller = AgendaController(store, now: () => _now); + final controller = _controller(store, now: () => _now); addTearDown(controller.dispose); await controller.load(); store.fail = true; @@ -278,7 +265,7 @@ void main() { kind: AgendaKind.exam, endsAt: _now.add(const Duration(hours: 1)), )]); - final controller = AgendaController(store, now: () => clock); + final controller = _controller(store, now: () => clock); addTearDown(controller.dispose); await controller.load(); final snapshot = controller.entries.single; @@ -297,7 +284,7 @@ void main() { test('Completion actions resolve current metadata rather than stale snapshots', () async { final store = _Store([_item(kind: AgendaKind.exam, endsAt: _now.add(const Duration(hours: 1)))]); - final controller = AgendaController(store, now: () => _now); + final controller = _controller(store, now: () => _now); addTearDown(controller.dispose); await controller.load(); final snapshot = controller.entries.single; @@ -319,7 +306,7 @@ void main() { final store = _Store([_item( kind: kind, done: !target, handled: true, )]); - final controller = AgendaController(store, now: () => _now); + final controller = _controller(store, now: () => _now); addTearDown(controller.dispose); await controller.load(); final snapshot = controller.entries.single; @@ -342,7 +329,7 @@ void main() { test('Queued metadata edits read completion at execution time for $kind', () async { final store = _Store([_item(kind: kind, handled: true)]); - final controller = AgendaController(store, now: () => _now); + final controller = _controller(store, now: () => _now); addTearDown(controller.dispose); await controller.load(); final snapshot = controller.entries.single; @@ -365,7 +352,7 @@ void main() { test('Default save can still explicitly change completion for $kind', () async { final store = _Store([_item(kind: kind, handled: true)]); - final controller = AgendaController(store, now: () => _now); + final controller = _controller(store, now: () => _now); addTearDown(controller.dispose); await controller.load(); expect(await controller.save(controller.entries.single.copyWith(done: true)), isTrue); @@ -375,7 +362,7 @@ void main() { test('Metadata edits cannot restore legacy migration over a newer undo', () async { final store = _Store([_item(done: true)]); - final controller = AgendaController(store, now: () => _now); + final controller = _controller(store, now: () => _now); addTearDown(controller.dispose); await controller.load(); final snapshot = controller.entries.single; @@ -395,7 +382,7 @@ void main() { kind: AgendaKind.exam, local: false, endsAt: _now.add(const Duration(hours: 1)), )]); - final controller = AgendaController(store, now: () => clock); + final controller = _controller(store, now: () => clock); addTearDown(controller.dispose); await controller.load(); await controller.setDone('item', true); @@ -423,7 +410,7 @@ void main() { test('Local content protection and distinct exam identities are not conflated', () async { final store = _Store([_item(kind: AgendaKind.exam, handled: true)]); - final controller = AgendaController(store, now: () => _now); + final controller = _controller(store, now: () => _now); addTearDown(controller.dispose); await controller.load(); await controller.replaceExams('term', [_item( @@ -432,7 +419,7 @@ void main() { expect(controller.entries.single.title, '日程'); expect(controller.entries.single.local, isTrue); final remoteStore = _Store([_item(kind: AgendaKind.exam, local: false, handled: true)]); - final remote = AgendaController(remoteStore, now: () => _now); + final remote = _controller(remoteStore, now: () => _now); addTearDown(remote.dispose); await remote.load(); await remote.replaceExams('term', [_item( @@ -445,7 +432,7 @@ void main() { test('Immediate deletion undo preserves a reopened exam opportunity marker', () async { final store = _Store([_item(kind: AgendaKind.exam)]); - final controller = AgendaController(store, now: () => _now); + final controller = _controller(store, now: () => _now); addTearDown(controller.dispose); await controller.load(); await controller.setDone('item', false); @@ -466,7 +453,7 @@ void main() { startsAt: _now, source: DeadlineSource.eclass, local: false, ); final store = _Store([raw]); - final controller = AgendaController(store, now: () => _now); + final controller = _controller(store, now: () => _now); addTearDown(controller.dispose); await controller.load(); await controller.setDone('ddl', true); @@ -480,7 +467,7 @@ void main() { var clock = _now; final end = _now.add(const Duration(minutes: 1)); final store = _Store([_item(kind: AgendaKind.exam, endsAt: end)]); - final controller = AgendaController(store, now: () => clock); + final controller = _controller(store, now: () => clock); try { await controller.load(); controller.setCompletionClockActive(true); @@ -505,7 +492,7 @@ void main() { var clock = _now; final end = _now.add(const Duration(minutes: 1)); final store = _Store([_item(kind: AgendaKind.exam, endsAt: end)]); - final controller = AgendaController(store, now: () => clock); + final controller = _controller(store, now: () => clock); try { await controller.load(); controller.setCompletionClockActive(true); @@ -514,6 +501,8 @@ void main() { clock = end; await tester.pump(const Duration(minutes: 1)); expect(store.writes, 1); + expect(_reportedErrors, hasLength(1)); + expect(_reportedErrors.single, contains('simulated agenda write failure')); expect(controller.entries.single.examAutoCompletionHandled, isFalse); expect(controller.entries.single.overdue(clock), isFalse); clock = end.add(const Duration(seconds: 29)); @@ -535,7 +524,7 @@ void main() { final store = _Store([_item( kind: AgendaKind.exam, endsAt: _now.add(const Duration(minutes: 1)), )]); - final agenda = AgendaController(store, now: () => clock); + final agenda = _controller(store, now: () => clock); final app = AppController( store: MemoryScheduleStore(), school: SchoolService(), schoolStorage: MemorySchoolStorage(), preferences: MemoryPreferences(), @@ -569,7 +558,7 @@ void main() { for (final kind in [AgendaKind.event, AgendaKind.exam]) { testWidgets('$kind list and detail both expose reversible completion', (tester) async { final raw = _item(kind: kind, handled: true); - final agenda = AgendaController(_Store([raw]), now: () => _now); + final agenda = _controller(_Store([raw]), now: () => _now); final app = TestAppController(agenda: agenda); await app.initialize(); await tester.pumpWidget(MaterialApp( diff --git a/test/support/memory_diagnostics_io.dart b/test/support/memory_diagnostics_io.dart deleted file mode 100644 index ccbdc3c..0000000 --- a/test/support/memory_diagnostics_io.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; - -/// A test-only file system for diagnostics. Completion clock tests run in a -/// FakeAsync zone: real disk IO queued there can outlive its retired zone and -/// strand the shared log queue. Immediate memory futures keep failures visible -/// without making the state-machine tests depend on native disk/plugin work. -final class MemoryDiagnosticsIo extends IOOverrides { - final _files = >{}; - - @override - Directory createDirectory(String path) => _Directory(this, path); - - @override - File createFile(String path) => _File(this, path); -} - -class _Directory extends Fake implements Directory { - _Directory(this.owner, this.path); - - final MemoryDiagnosticsIo owner; - @override - final String path; - - @override - Future create({bool recursive = false}) async => this; - - @override - Stream list({ - bool recursive = false, - bool followLinks = true, - }) => Stream.fromIterable([ - for (final file in owner._files.keys.toList()) - if (file.startsWith('$path/')) _File(owner, file), - ]); -} - -class _File extends Fake implements File { - _File(this.owner, this.path); - - final MemoryDiagnosticsIo owner; - @override - final String path; - @override - Uri get uri => Uri.file(path); - - @override - Future writeAsString( - String contents, { - FileMode mode = FileMode.write, - Encoding encoding = utf8, - bool flush = false, - }) async { - final bytes = encoding.encode(contents); - owner._files[path] = [ - if (mode == FileMode.append) ...(owner._files[path] ?? []), - ...bytes, - ]; - return this; - } - - @override - Stream> openRead([int? start, int? end]) => - Stream>.value( - List.of(owner._files[path] ?? []).sublist(start ?? 0, end), - ); - - @override - Future readAsString({Encoding encoding = utf8}) async => - encoding.decode(owner._files[path] ?? []); - - @override - Future delete({bool recursive = false}) async { - owner._files.remove(path); - return this; - } -} From 0f13348817e96c47ba64747d06c5e01a0e908f03 Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Wed, 11 Nov 2026 16:40:27 +0800 Subject: [PATCH 12/24] fix(agenda): initialize stable layout card span directly --- lib/agenda/timeline_layout.dart | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/agenda/timeline_layout.dart b/lib/agenda/timeline_layout.dart index 5454217..63601da 100644 --- a/lib/agenda/timeline_layout.dart +++ b/lib/agenda/timeline_layout.dart @@ -35,16 +35,15 @@ class AgendaTimelineLayout { AgendaTimelineLayout( Iterable entries, { required Duration minimumEventSpan, - required Duration deadlineCardSpan, + required this.deadlineCardSpan, int layoutDays = AgendaEntry.defaultTailDays, - }) : _deadlineCardSpan = deadlineCardSpan, - _reservations = _reserveLanes( + }) : _reservations = _reserveLanes( entries, minimumEventSpan: minimumEventSpan, layoutDays: layoutDays, ); - final Duration _deadlineCardSpan; + final Duration deadlineCardSpan; final List<_LaneReservation> _reservations; List atZoom(double zoom) { @@ -52,7 +51,7 @@ class AgendaTimelineLayout { throw RangeError.value(zoom, 'zoom', 'Must be finite and at least one'); } final fixedWidthSpan = Duration( - microseconds: (_deadlineCardSpan.inMicroseconds / zoom).ceil(), + microseconds: (deadlineCardSpan.inMicroseconds / zoom).ceil(), ); return [ for (final interval in _reservations) From af486c3f71a5273dd5fe11b5c1f62277c2a5f8fd Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Wed, 11 Nov 2026 16:48:43 +0800 Subject: [PATCH 13/24] fix(test): isolate merge cache updates and badge clock --- test/deadline_cutoff_badge_test.dart | 5 ++++- test/timeline_cache_merge_test.dart | 25 ++++++++++++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/test/deadline_cutoff_badge_test.dart b/test/deadline_cutoff_badge_test.dart index e249a65..01db659 100644 --- a/test/deadline_cutoff_badge_test.dart +++ b/test/deadline_cutoff_badge_test.dart @@ -1,3 +1,4 @@ +import 'package:bitree/agenda/controller.dart'; import 'package:bitree/agenda/model.dart'; import 'package:bitree/agenda/ui/agenda_list_tile.dart'; import 'package:bitree/l10n/app_localizations_zh.dart'; @@ -47,7 +48,9 @@ Future _showHeader( tester.view.physicalSize = const Size(390, 844); addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetPhysicalSize); - final app = TestAppController(entries: entries); + final app = TestAppController( + agenda: AgendaController(MemoryAgendaStore(entries), now: () => _now), + ); await app.initialize(); await tester.pumpWidget( MaterialApp( diff --git a/test/timeline_cache_merge_test.dart b/test/timeline_cache_merge_test.dart index 86f41ca..9fb2355 100644 --- a/test/timeline_cache_merge_test.dart +++ b/test/timeline_cache_merge_test.dart @@ -8,14 +8,22 @@ import 'package:flutter_test/flutter_test.dart'; final _now = DateTime.utc(2030, 1, 2, 8); -Future _show(WidgetTester tester, Widget child) async { +Future> _show(WidgetTester tester, Widget child) async { + final content = ValueNotifier(child); + addTearDown(content.dispose); await tester.pumpWidget(MaterialApp( theme: bitreeTheme(Brightness.light), home: Scaffold(body: Center(child: SizedBox( - width: 240, height: 100, child: child, + width: 240, + height: 100, + child: ValueListenableBuilder( + valueListenable: content, + builder: (_, current, _) => current, + ), ))), )); await tester.pumpAndSettle(); + return content; } void main() { @@ -37,10 +45,11 @@ void main() { ), onTap: onTap, ); - await _show(tester, card(200, () => tapped = 1)); + final content = await _show(tester, card(200, () => tapped = 1)); final title = tester.widget(find.text(entry.title)); final state = tester.state(find.byType(AgendaTimelineEntry)); - await _show(tester, card(230, () => tapped = 2)); + content.value = card(230, () => tapped = 2); + await tester.pumpAndSettle(); expect(tester.state(find.byType(AgendaTimelineEntry)), same(state)); expect(tester.widget(find.text(entry.title)), same(title)); final surface = tester.widget( @@ -74,9 +83,10 @@ void main() { ), onTap: () {}, ); - await _show(tester, card(entry, 190)); + final content = await _show(tester, card(entry, 190)); final title = tester.widget(find.text(entry.title)); - await _show(tester, card(entry, 220)); + content.value = card(entry, 220); + await tester.pumpAndSettle(); expect(tester.widget(find.text(entry.title)), same(title)); EventTimelinePainter painter() => tester.widget( find.byWidgetPredicate((widget) => widget is CustomPaint && @@ -84,7 +94,8 @@ void main() { ).painter! as EventTimelinePainter; expect(painter().geometry.endX, 220); final originalAlpha = painter().fill.a; - await _show(tester, card(entry.copyWith(done: true), 220)); + content.value = card(entry.copyWith(done: true), 220); + await tester.pumpAndSettle(); expect(tester.widget(find.text(entry.title)), isNot(same(title))); expect(painter().fill.a, closeTo(originalAlpha * .5, .001)); expect(tester.takeException(), isNull); From 08ed417944d3bdf610bc0a1ae5a18179c0493d51 Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Wed, 11 Nov 2026 16:56:40 +0800 Subject: [PATCH 14/24] docs(agenda): align merged architecture with feature semantics --- docs/architecture.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 0b2f02e..18e8d38 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,24 +80,24 @@ USTC 风控采集失败时遵循学校登录页的协议:仅在风控请求出 ## 日程视图 -日程页提供列表与横向多泳道时间轴,共用类型筛选,读取同一个 `AgendaController`,不混入课表课程。列表保留未完成/已完成分页;时间轴将两种状态同表排布,不按完成状态分配泳道,已完成或已结束条目仍沿用弱化样式。视图模式通过 `SharedPreferencesAsync` 的 `agendaViewMode` 保存,缺省为瀑布图(时间轴),已有有效偏好仍按用户选择恢复;缩放和浏览位置不写入日程文件。切换视图保留本次浏览位置,离开日程页后再进入会将时间轴定位到今日 12:00 居中。 +日程页提供列表与横向多泳道时间轴,共用类型筛选,读取同一个 `AgendaController`,不混入课表课程。列表保留未完成/已完成分页;时间轴将两种状态同表排布,不按完成状态分配泳道,已完成条目沿用弱化样式。普通日程结束未完成会逾期,考试结束自动完成一次,撤销后保留未完成选择;时间结束与完成分开计算,详见 [日程完成状态](agenda-completion.md)。视图模式通过 `SharedPreferencesAsync` 的 `agendaViewMode` 保存,缺省为瀑布图(时间轴),已有有效偏好仍按用户选择恢复;缩放和浏览位置不写入日程文件。切换视图保留本次浏览位置,离开日程页后再进入会将时间轴定位到今日 12:00 居中。 -`agenda/timeline_layout.dart` 分别计算泳道占位和当前绘制区间。分行使用最小缩放时的卡片占位,缩放和平移不会重新分行;数据、窗口宽度或字体大小改变时才重新计算占位。DDL 正文保持 144 逻辑像素宽并随字体放大;普通日程和考试按真实时长绘制,最少显示固定 2 逻辑像素,过窄时通过点击、悬停或长按查看完整信息。`layoutEnd` 是当前显示终点,用于绘制、可见区域筛选和拖尾避让,不修改条目的真实时间。`tailDays` 默认为 4,可在 DDL 新建/编辑面板中使用滑动条在 1~30 天之间按整天调整,随条目保存;旧数据缺少该字段时使用 4 天。 +`agenda/timeline_layout.dart` 将完整泳道预留与缩放绘制投影分开。普通 DDL 预留 `[有效截止-D天,真实截止]`,仅开始项目向右预留 D 天,普通日程和考试预留 `[真实开始-D天,真实结束]`;数据和基础布局参数不变时,缩放和平移不会重新分行。D 是设置中的全局排版宽度,默认 4、范围 1~30 天,覆盖旧的单条 `tailDays`(该字段仅保留存储兼容)。DDL 主体和日程左侧信息区按最小缩放 D−0.25 天对应的屏幕像素定宽,默认约 3.75 基线天,不采用 144px 独立正文或随缩放增长的日历天数拖尾。`paintStart/paintEnd` 表示当前实际绘制边界,真实时段和伪截止延伸段随缩放变化。纵向范围取全部筛选条目的最高泳道,不依赖横向可见条目。 `AgendaTimelineEntry` 只负责条目的外观和交互,不处理坐标或泳道。日程与考试在标题下常驻真实起止时间,跨天时分两行标出日期;全天日程显示日期范围。DDL 保留课程/来源,并另列截止时间。长按或鼠标悬停显示包含开始、结束或截止日期、课程及地点的分行预览;点按沿用详情抽屉。卡片最小高度为 56 逻辑像素,并随字体放大,确保完整时间与来源信息有独立空间。 -DDL 的右端为 `dueAt`,拖尾从左侧渐隐;只有开放时间的测验以开放时间为左端,拖尾向右延伸。拖尾按 `tailDays - 0.25` 个日历日计算,随时间轴缩放,并在同泳道相邻卡片处截短;整条宽度至少容纳正文。渐变区域不拦截点击,短日程的扩展点击区域也不会越过下一张卡片。全天 DDL 的右端沿用模型定义,为对应日期次日零点。 +DDL 主体右端为有效 `dueAt`,从左侧渐隐;仅有开放时间的项目从左向右淡出。主体屏幕定宽,不因邻卡裁短信息区。每日伪提前窗口支持关闭、跨日、两端包含及起止同点;原始截止不写回覆盖。命中窗口时,有效与真实截止之间绘制随时间缩放的宽斜纹矩形,真实标线使用变淡色并始终最后绘制,包括重叠情形。普通日程和考试真实时段为两条实线之间的无渐变填充,左信息区使用半透明斜纹及向内半线宽边框。各区域完成透明度都乘原 alpha,已半透明区域再衰减,详见 [布局与伪截止规则](deadline-cutoff.md)。全天 DDL 的真实截止仍为对应日期次日零点,只有有效截止可派生提前。 时间轴以视口中心时间和连续缩放比例计算坐标,不创建覆盖历史与未来的巨大滚动画布。默认一天一格,最多一小时一格;基准格宽为 48~84 逻辑像素。平移没有预设日期边界,只绘制可见区域附近的网格和条目。顶部工具栏只保留单行并排的「今天」按钮与自适应宽度的缩放滑条,不显示当前日期和常驻的「时间/格」文本,缩放比例仅保留无障碍语义描述。表头基准高度为 24 逻辑像素:不显示小时时,第一行显示日期、第二行居中显示星期;显示小时时,第一行将「日期 星期」作为一个整体居中,第二行显示小时。标签宽度测量包含完整星期与中间空格,固定在对应时间格的中上方,以完整文字绘制并在视口边缘自然裁切,不贴靠屏幕边缘或变成省略号。首个日程条距表头分隔线 12 逻辑像素,与日程条之间的轨道间距一致。工具栏不再包含问号帮助图标及其提示。当前时间线单独在前景绘制,覆盖日程条但不拦截交互,也不绘制圆点。条目单击复用日程详情弹层,编辑、删除、同步后由功能控制器通知刷新。 -时间轴使用独立的语义色,不复用楼栋调色板。考试、大物实验 DDL、乐学 DDL、新延河课堂 DDL 和 MOOC DDL 分别使用黄、蓝、橙、紫和薄荷绿,且每种颜色直接定义浅色与深色的 `fill`、`text`、`accent`。背景使用 fill、文字使用 text、DDL 右侧条与其他日程边框使用 accent。未完成且逾期的 DDL 沿用普通 DDL 的左淡出卡片样式,将 fill 替换为 errorContainer、文字替换为 onErrorContainer、强调色替换为 error;背景右侧再以 20% onErrorContainer 绘制大号警告图标虚影,来源色和错误色不进行长距离混色。末尾直接显示截止时间与“已逾期”。完成该条目后移除红色状态与虚影,并恢复来源色的淡化样式。已完成 DDL 和已结束考试的背景、文字、图标及边框/右侧条统一使用 50% 不透明度(50% 透明),保留各自原色;普通活动的完成样式不变。列表和课表不使用这套映射。 +时间轴使用独立的语义色,不复用楼栋调色板。考试、大物实验 DDL、乐学 DDL、新延河课堂 DDL 和 MOOC DDL 分别使用黄、蓝、橙、紫和薄荷绿,且每种颜色直接定义浅色与深色的 `fill`、`text`、`accent`。背景使用 fill、文字使用 text、DDL 右侧条与其他日程边框使用 accent。未完成且逾期的 DDL 沿用普通 DDL 的左淡出卡片样式,将 fill 替换为 errorContainer、文字替换为 onErrorContainer、强调色替换为 error;背景右侧再以 20% onErrorContainer 绘制大号警告图标虚影,来源色和错误色不进行长距离混色。末尾直接显示截止时间与“已逾期”。完成该条目后移除红色状态与虚影,并恢复来源色的淡化样式。所有已完成项目的背景、文字、图标及标线统一在原透明度上乘 0.5;左信息区条纹、伪截止延伸和真实标线各自的半透明系数再与完成系数相乘。日程自定义颜色优先,仍保留其原 alpha,不以固定 0.5 覆盖。列表和课表不使用这套映射。 主页课表网格中的日程块不复用时间轴的语义来源色:普通活动使用 `primaryContainer` 背景、`onPrimaryContainer` 文字和 1px `primary` 边框,考试使用 `errorContainer`/`onErrorContainer`/`error`。浅色模式下容器色与页面底色天然接近,可见性由 tone 固定的边框和文字承担,因此开启全局系统调色板后仍能辨认;不使用 `outlineVariant` 等低对比细分隔色。 关闭全局系统调色板时,所选 JSON 调色板只覆盖课程与普通日程块,不修改页面背景、全局 `ColorScheme` 或应用明暗模式。内置 Mocha/Macchiato/Frappé 位于 `assets/palettes/`,颜色来自 [Catppuccin palette](https://github.com/catppuccin/palette/blob/main/palette.json),均适用于深色模式。课程使用所属区域的强调色,普通日程使用 `agenda`;最终底色统一为 `Color.lerp(base, accent, 0.24)`,文字统一使用 `text`。主页普通日程块与时间轴普通活动、无平台来源的手动 DDL 使用同一组颜色。时间轴中的平台作业、MOOC、物理实验与考试继续保留来源映射;未完成且逾期的 DDL 使用轻量错误色蒙版,主页考试沿用错误色,完成淡化规则不变。系统调色板可用且已启用时优先于 JSON 配色。 设置的「外观」二级页面提供明暗模式切换、浅色/深色调色板独立选择、JSON 文件导入、自定义配色的删除按钮及系统调色板开关,所有选项均可直接使用。关于弹窗中的五次连击仍可开启本次运行的开发者模式,但目前不影响任何功能。导入成功后将完整配色内容复制到应用设置并应用到对应模式,不依赖原文件继续存在;校验或保存失败时保留原选择。删除正在使用的自定义配色会将对应模式恢复为默认。旧版本的 Cat 配色选择会自动恢复。Android 小组件接收浅色和深色课程的最终颜色,其系统课程配色开关仍独立控制;Android 12+ 的组件整体背景与文字继续使用系统颜色。文件格式与字段说明见 [调色板文件](palettes.md)。 -操作方式:拖动或普通滚轮横向浏览时间,双指捏合、`Ctrl + 滚轮` 或滑条连续缩放;上下拖动或 `Shift + 滚轮` 浏览泳道。时间轴隐藏显式与桌面自动添加的纵向滚动条,仍保留纵向滚动和惯性。单指或鼠标拖动释放后,使用 Flutter 的摩擦物理模拟继续滑行;缩放、新输入、隐藏视图或离开页面时停止惯性,触控板自身的惯性输入不额外叠加甩动。「今天」按钮回到今日居中。日程列表的完成状态以分段选择过滤,不使用横向分页;时间轴的横拖只用于时间导航。 +操作方式:拖动或普通滚轮横向浏览时间,双指捏合、`Ctrl + 滚轮` 或滑条连续缩放;上下拖动或 `Shift + 滚轮` 浏览泳道。时间轴隐藏显式与桌面自动添加的纵向滚动条,仍保留纵向滚动和惯性。纵向拖动通过 `ScrollPosition.drag/update/end` 使用 Flutter 的惯性和回弹,横向中心日期使用摩擦模拟;正常 overscroll 保留,筛选或尺寸缩小导致的旧偏移与卡片裁剪同步纠正。平移中心日期通过局部监听更新视口,网格文字和卡片内容层复用缓存,动态几何独立更新;缩放、新输入、隐藏视图或离开页面时停止惯性,触控板自身的惯性输入不额外叠加甩动。「今天」按钮回到今日居中。日程列表的完成状态以分段选择过滤,不使用横向分页;时间轴的横拖只用于时间导航。 ## 中国大学MOOC @@ -122,7 +122,7 @@ DDL 的右端为 `dueAt`,拖尾从左侧渐隐;只有开放时间的测验 ## 手势与布局 - `SwipePager` 统一鼠标与触摸分页,拖过半页即报告新页,程序切页不报告中间页。 -- `SwipeActionTile` 以明确的横向意图接管单条操作,不增加按住等待。短滑露出按钮,长滑达到阈值后松手执行;左滑删除,右滑完成或恢复 DDL。快速收回和反向拖回都可以取消。`TapRegion` 处理外部触摸收起,最近的纵向 `ScrollPosition` 处理滚动收起,不额外维护全局打开条目。滑出、按钮停靠与回弹共用动画,保存失败回到原位。行的增删尺寸动画由 `AnimatedSliverChildren` 统一处理。 +- `SwipeActionTile` 以明确的横向意图接管单条操作,不增加按住等待。短滑露出按钮,长滑达到阈值后松手执行;左滑删除,右滑完成或恢复日程、考试或 DDL。快速收回和反向拖回都可以取消。`TapRegion` 处理外部触摸收起,最近的纵向 `ScrollPosition` 处理滚动收起,不额外维护全局打开条目。滑出、按钮停靠与回弹共用动画,保存失败回到原位。行的增删尺寸动画由 `AnimatedSliverChildren` 统一处理。 - `PullToRevealList` 管理下拉手势和展开状态,`RevealScrollController` 在布局阶段同步内容高度与滚动位置。用户重新拖动时解除滚动保持,直接接管滚动。 - `AppMenuAnchor` 使用原生 `RawMenuAnchor` 管理焦点与菜单生命周期,统一面板的开合动效。菜单高度取触发器上下的实际可用空间。 - `SheetLayout`、`EditorSheet` 和主题共同约束弹层尺寸、固定操作区与可滚动内容。地图的缩放、绘制和命中测试由 `WencuiFloorMap` 管理。 From 6c5ebe114ece080142349a7d6494d134301daae0 Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Wed, 11 Nov 2026 21:18:00 +0800 Subject: [PATCH 15/24] feat(agenda): make the event information bar optional and restyle it Add a settings switch (on by default) that hides the left information card of ordinary events and exams in the timeline: its text, stripes and inner border disappear and the layout no longer reserves the lead, so back-to-back intervals share a lane. Real intervals, boundary lines and taps are untouched, and deadlines keep their own information cards. Restyle the visible information region: the inner half-line border now uses the accent color, and the 45-degree bands alternate between the solid fill and its half alpha instead of the previous .5/.25 pair. Completion fading still multiplies the existing alpha. Includes persistence, projection, pixel and settings-switch regressions. --- docs/architecture.md | 2 +- docs/deadline-cutoff.md | 13 +- lib/agenda/timeline_layout.dart | 22 ++- lib/agenda/ui/agenda_timeline.dart | 14 +- lib/agenda/ui/event_timeline_entry.dart | 140 ++++++++-------- lib/app/settings_controller.dart | 27 +++ lib/app/ui/settings_screen.dart | 12 ++ lib/l10n/app_en.arb | 2 + lib/l10n/app_localizations.dart | 12 ++ lib/l10n/app_localizations_en.dart | 6 + lib/l10n/app_localizations_zh.dart | 6 + lib/l10n/app_zh.arb | 2 + test/event_info_bar_test.dart | 208 ++++++++++++++++++++++++ test/event_timeline_style_test.dart | 51 +++++- 14 files changed, 439 insertions(+), 78 deletions(-) create mode 100644 test/event_info_bar_test.dart diff --git a/docs/architecture.md b/docs/architecture.md index 18e8d38..f297fa9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,7 +86,7 @@ USTC 风控采集失败时遵循学校登录页的协议:仅在风控请求出 `AgendaTimelineEntry` 只负责条目的外观和交互,不处理坐标或泳道。日程与考试在标题下常驻真实起止时间,跨天时分两行标出日期;全天日程显示日期范围。DDL 保留课程/来源,并另列截止时间。长按或鼠标悬停显示包含开始、结束或截止日期、课程及地点的分行预览;点按沿用详情抽屉。卡片最小高度为 56 逻辑像素,并随字体放大,确保完整时间与来源信息有独立空间。 -DDL 主体右端为有效 `dueAt`,从左侧渐隐;仅有开放时间的项目从左向右淡出。主体屏幕定宽,不因邻卡裁短信息区。每日伪提前窗口支持关闭、跨日、两端包含及起止同点;原始截止不写回覆盖。命中窗口时,有效与真实截止之间绘制随时间缩放的宽斜纹矩形,真实标线使用变淡色并始终最后绘制,包括重叠情形。普通日程和考试真实时段为两条实线之间的无渐变填充,左信息区使用半透明斜纹及向内半线宽边框。各区域完成透明度都乘原 alpha,已半透明区域再衰减,详见 [布局与伪截止规则](deadline-cutoff.md)。全天 DDL 的真实截止仍为对应日期次日零点,只有有效截止可派生提前。 +DDL 主体右端为有效 `dueAt`,从左侧渐隐;仅有开放时间的项目从左向右淡出。主体屏幕定宽,不因邻卡裁短信息区。每日伪提前窗口支持关闭、跨日、两端包含及起止同点;原始截止不写回覆盖。命中窗口时,有效与真实截止之间绘制随时间缩放的宽斜纹矩形,真实标线使用变淡色并始终最后绘制,包括重叠情形。普通日程和考试真实时段为两条实线之间的无渐变填充,左信息区使用实填充色与其半透明交替的斜纹及向内半线宽高亮色边框;设置中的“日程与考试信息栏”开关(默认开启)可隐藏日程与考试的左信息栏并同时取消其左侧预留,占位改为 `[真实开始, 真实结束]`,DDL、仅开始项目与真实时段绘制不受影响。各区域完成透明度都乘原 alpha,已半透明区域再衰减,详见 [布局与伪截止规则](deadline-cutoff.md)。全天 DDL 的真实截止仍为对应日期次日零点,只有有效截止可派生提前。 时间轴以视口中心时间和连续缩放比例计算坐标,不创建覆盖历史与未来的巨大滚动画布。默认一天一格,最多一小时一格;基准格宽为 48~84 逻辑像素。平移没有预设日期边界,只绘制可见区域附近的网格和条目。顶部工具栏只保留单行并排的「今天」按钮与自适应宽度的缩放滑条,不显示当前日期和常驻的「时间/格」文本,缩放比例仅保留无障碍语义描述。表头基准高度为 24 逻辑像素:不显示小时时,第一行显示日期、第二行居中显示星期;显示小时时,第一行将「日期 星期」作为一个整体居中,第二行显示小时。标签宽度测量包含完整星期与中间空格,固定在对应时间格的中上方,以完整文字绘制并在视口边缘自然裁切,不贴靠屏幕边缘或变成省略号。首个日程条距表头分隔线 12 逻辑像素,与日程条之间的轨道间距一致。工具栏不再包含问号帮助图标及其提示。当前时间线单独在前景绘制,覆盖日程条但不拦截交互,也不绘制圆点。条目单击复用日程详情弹层,编辑、删除、同步后由功能控制器通知刷新。 diff --git a/docs/deadline-cutoff.md b/docs/deadline-cutoff.md index fdd0b06..18b0e8f 100644 --- a/docs/deadline-cutoff.md +++ b/docs/deadline-cutoff.md @@ -48,11 +48,14 @@ JSON 设置保存;关闭仍保留所选时间。时间选择页常驻原生 Ma 普通日程和考试的左侧信息区同样使用全局 `D` 天预留及 `D − 0.25` 基线天 的屏幕定宽,整体占位为 `[真实开始 − D 天, 真实结束]`。真实时段由开始、 -结束两条 3px 实线和无渐变填充组成,按实际时距缩放;左侧是同配色的 -45° 半透明斜纹(两带透明度系数 0.5/0.25)及向内绘制的 1.5px 填充色边框。 -完成时在原颜色透明度上再乘 0.5,不能把半透明部分覆盖成固定透明度。 -两条时间线重合时结束线最后绘制。所有区域保持视口裁剪,日期与真实时距 -不因左侧信息区预留而改变。 +结束两条 3px 实线和无渐变填充组成,按实际时距缩放;左侧是 45° 斜纹 +(两带交替使用实填充色与其 0.5 半透明)及向内绘制的 1.5px 高亮色 +(accent)边框。完成时在原颜色透明度上再乘 0.5,不能把半透明部分覆盖成 +固定透明度。两条时间线重合时结束线最后绘制。所有区域保持视口裁剪, +日期与真实时距不因左侧信息区预留而改变。设置中的“日程与考试信息栏” +开关(默认开启)可隐藏普通日程和考试的信息栏文字、斜纹与边框,同时 +取消其左侧排版预留(占位改为 `[真实开始, 真实结束]`);开始/结束标线、 +中间填充和点击区域不受影响,DDL 与仅开始时间项目不受该开关影响。 完成、撤销、考试自动一次和旧数据迁移规则见 [日程完成状态](agenda-completion.md)。 diff --git a/lib/agenda/timeline_layout.dart b/lib/agenda/timeline_layout.dart index 63601da..6fdf890 100644 --- a/lib/agenda/timeline_layout.dart +++ b/lib/agenda/timeline_layout.dart @@ -37,13 +37,19 @@ class AgendaTimelineLayout { required Duration minimumEventSpan, required this.deadlineCardSpan, int layoutDays = AgendaEntry.defaultTailDays, + this.reserveEventInformation = true, }) : _reservations = _reserveLanes( entries, minimumEventSpan: minimumEventSpan, layoutDays: layoutDays, + reserveEventInformation: reserveEventInformation, ); final Duration deadlineCardSpan; + + /// When the event information card is hidden, events reserve and paint only + /// their real interval instead of an information-width lead on the left. + final bool reserveEventInformation; final List<_LaneReservation> _reservations; List atZoom(double zoom) { @@ -61,12 +67,14 @@ class AgendaTimelineLayout { start: interval.start, end: interval.end, layoutEnd: interval.layoutEnd, - paintStart: interval.entry.startOnly + paintStart: + interval.entry.startOnly || + (!interval.entry.isDeadline && !reserveEventInformation) ? interval.entry.startsAt : (interval.entry.isDeadline - ? interval.entry.dueAt - : interval.entry.startsAt) - .subtract(fixedWidthSpan), + ? interval.entry.dueAt + : interval.entry.startsAt) + .subtract(fixedWidthSpan), paintEnd: interval.entry.startOnly ? interval.entry.startsAt.add(fixedWidthSpan) : interval.end, @@ -82,6 +90,7 @@ List layoutAgendaTimeline( Iterable entries, { required Duration minimumEventSpan, int layoutDays = AgendaEntry.defaultTailDays, + bool reserveEventInformation = true, }) => AgendaTimelineLayout( entries, minimumEventSpan: minimumEventSpan, @@ -89,17 +98,20 @@ List layoutAgendaTimeline( microseconds: (Duration.microsecondsPerDay * (layoutDays - .25)).round(), ), layoutDays: layoutDays, + reserveEventInformation: reserveEventInformation, ).atZoom(1); List<_LaneReservation> _reserveLanes( Iterable entries, { required Duration minimumEventSpan, required int layoutDays, + required bool reserveEventInformation, }) { if (layoutDays < 1 || layoutDays > AgendaEntry.maxTailDays) { throw RangeError.range(layoutDays, 1, AgendaEntry.maxTailDays, 'layoutDays'); } - DateTime reservationStart(AgendaEntry entry) => entry.startOnly + DateTime reservationStart(AgendaEntry entry) => + entry.startOnly || (!entry.isDeadline && !reserveEventInformation) ? entry.startsAt : (entry.isDeadline ? entry.dueAt : entry.startsAt) .subtract(Duration(days: layoutDays)); diff --git a/lib/agenda/ui/agenda_timeline.dart b/lib/agenda/ui/agenda_timeline.dart index 74c69dd..0b91b02 100644 --- a/lib/agenda/ui/agenda_timeline.dart +++ b/lib/agenda/ui/agenda_timeline.dart @@ -105,6 +105,7 @@ class _AgendaTimelineState extends State List? _cachedEntries; DeadlineCutoffWindow? _cachedCutoff; int? _cachedLayoutDays; + bool? _cachedEventInfoBar; Duration? _cachedMinimumEventSpan; Duration? _cachedDeadlineCardSpan; double? _cachedZoom; @@ -123,14 +124,17 @@ class _AgendaTimelineState extends State ) { final cutoff = widget.controller.settings.deadlineCutoff.value; final layoutDays = widget.controller.settings.agendaLayoutDays.value; + final eventInfoBar = widget.controller.settings.agendaEventInfoBar.value; if (!identical(_cachedEntries, widget.entries) || _cachedCutoff != cutoff || _cachedLayoutDays != layoutDays || + _cachedEventInfoBar != eventInfoBar || _cachedMinimumEventSpan != minimumEventSpan || _cachedDeadlineCardSpan != deadlineCardSpan) { _cachedEntries = widget.entries; _cachedCutoff = cutoff; _cachedLayoutDays = layoutDays; + _cachedEventInfoBar = eventInfoBar; _cachedMinimumEventSpan = minimumEventSpan; _cachedDeadlineCardSpan = deadlineCardSpan; _cachedZoom = null; @@ -140,6 +144,7 @@ class _AgendaTimelineState extends State minimumEventSpan: minimumEventSpan, deadlineCardSpan: deadlineCardSpan, layoutDays: layoutDays, + reserveEventInformation: eventInfoBar, ); } if (_cachedZoom != _zoom) { @@ -157,6 +162,7 @@ class _AgendaTimelineState extends State ); widget.controller.settings.deadlineCutoff.addListener(_settingsChanged); widget.controller.settings.agendaLayoutDays.addListener(_settingsChanged); + widget.controller.settings.agendaEventInfoBar.addListener(_settingsChanged); _horizontalFling = AnimationController.unbounded(vsync: this) ..addListener(_tickHorizontal); } @@ -258,8 +264,10 @@ class _AgendaTimelineState extends State if (oldWidget.controller != widget.controller) { oldWidget.controller.settings.deadlineCutoff.removeListener(_settingsChanged); oldWidget.controller.settings.agendaLayoutDays.removeListener(_settingsChanged); + oldWidget.controller.settings.agendaEventInfoBar.removeListener(_settingsChanged); widget.controller.settings.deadlineCutoff.addListener(_settingsChanged); widget.controller.settings.agendaLayoutDays.addListener(_settingsChanged); + widget.controller.settings.agendaEventInfoBar.addListener(_settingsChanged); _cachedEntries = null; } if (oldWidget.filter != widget.filter) { @@ -309,6 +317,7 @@ class _AgendaTimelineState extends State _stopMotion(holdVertical: true); widget.controller.settings.deadlineCutoff.removeListener(_settingsChanged); widget.controller.settings.agendaLayoutDays.removeListener(_settingsChanged); + widget.controller.settings.agendaEventInfoBar.removeListener(_settingsChanged); _horizontalFling.dispose(); _releaseVerticalHold(); _lanes.dispose(); @@ -777,9 +786,11 @@ class _AgendaTimelineState extends State _microsecondsPerHour * pixelsPerHour; if (!entry.isDeadline) { + final showInformation = widget.controller.settings + .agendaEventInfoBar.value; final start = x(entry.startsAt); final end = x(entry.endsAt!); - final informationLeft = start - deadlineWidth; + final informationLeft = showInformation ? start - deadlineWidth : start; final left = math.max(0.0, informationLeft); final right = math.min(width, end); return Positioned( @@ -792,6 +803,7 @@ class _AgendaTimelineState extends State entry: entry, now: widget.now, informationWidth: deadlineWidth, + showInformation: showInformation, geometry: EventTimelineGeometry( informationLeft: informationLeft - left, startX: start - left, diff --git a/lib/agenda/ui/event_timeline_entry.dart b/lib/agenda/ui/event_timeline_entry.dart index 06eaf9e..b88325a 100644 --- a/lib/agenda/ui/event_timeline_entry.dart +++ b/lib/agenda/ui/event_timeline_entry.dart @@ -30,12 +30,14 @@ class EventTimelinePainter extends CustomPainter { required this.fill, required this.accent, this.lineWidth = 3, + this.showInformation = true, }); final EventTimelineGeometry geometry; final Color fill; final Color accent; final double lineWidth; + final bool showInformation; @override void paint(Canvas canvas, Size size) { @@ -45,24 +47,26 @@ class EventTimelinePainter extends CustomPainter { final information = Rect.fromLTRB( geometry.informationLeft, 0, geometry.startX, size.height, ); - // Palette alpha already contains completion fading. Apply the information - // region's extra half without replacing (and losing) the existing alpha. - AdjustedDeadlineExtensionPainter( - effectiveX: geometry.informationLeft, - realX: geometry.startX, - color: fill, - highOpacity: .5, - lowOpacity: .25, - ).paint(canvas, size); - final borderWidth = lineWidth / 2; - if (information.width >= borderWidth && size.height >= borderWidth) { - canvas.drawRect( - information.deflate(borderWidth / 2), - Paint() - ..color = fill - ..style = PaintingStyle.stroke - ..strokeWidth = borderWidth, - ); + if (showInformation) { + // Palette alpha already contains completion fading. Multiply the stripe + // bands without replacing (and losing) the existing alpha. + AdjustedDeadlineExtensionPainter( + effectiveX: geometry.informationLeft, + realX: geometry.startX, + color: fill, + highOpacity: 1, + lowOpacity: .5, + ).paint(canvas, size); + final borderWidth = lineWidth / 2; + if (information.width >= borderWidth && size.height >= borderWidth) { + canvas.drawRect( + information.deflate(borderWidth / 2), + Paint() + ..color = accent + ..style = PaintingStyle.stroke + ..strokeWidth = borderWidth, + ); + } } final intervalLeft = math.max(0.0, geometry.startX); final intervalRight = math.min(size.width, geometry.endX); @@ -88,7 +92,8 @@ class EventTimelinePainter extends CustomPainter { oldDelegate.geometry.endX != geometry.endX || oldDelegate.fill != fill || oldDelegate.accent != accent || - oldDelegate.lineWidth != lineWidth; + oldDelegate.lineWidth != lineWidth || + oldDelegate.showInformation != showInformation; } class EventTimelineEntry extends StatefulWidget { @@ -99,6 +104,7 @@ class EventTimelineEntry extends StatefulWidget { required this.geometry, required this.informationWidth, required this.onTap, + this.showInformation = true, }); final AgendaEntry entry; @@ -107,6 +113,10 @@ class EventTimelineEntry extends StatefulWidget { final double informationWidth; final VoidCallback onTap; + /// Hides the left information card's text, stripes and inner border. The + /// real interval, its fill and both boundary lines always stay painted. + final bool showInformation; + @override State createState() => _EventTimelineEntryState(); } @@ -183,61 +193,63 @@ class _EventTimelineEntryState extends State { geometry: geometry, fill: colors.fill, accent: colors.accent, + showInformation: widget.showInformation, ), child: Stack( clipBehavior: Clip.hardEdge, children: [ - Positioned( - left: geometry.informationLeft, - top: 0, - bottom: 0, - width: informationWidth, - child: _information ??= RepaintBoundary( - child: SizedBox( - key: ValueKey(('agenda-timeline-bar', entry.id)), - child: Padding( - padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.right, - style: TextStyle( - fontSize: 14, - height: 1.2, - fontWeight: FontWeight.w600, - color: colors.text, - decoration: entry.done - ? TextDecoration.lineThrough - : null, + if (widget.showInformation) + Positioned( + left: geometry.informationLeft, + top: 0, + bottom: 0, + width: informationWidth, + child: _information ??= RepaintBoundary( + child: SizedBox( + key: ValueKey(('agenda-timeline-bar', entry.id)), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 14, + height: 1.2, + fontWeight: FontWeight.w600, + color: colors.text, + decoration: entry.done + ? TextDecoration.lineThrough + : null, + ), ), - ), - const SizedBox(height: 2), - Text( - caption, - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.right, - style: TextStyle( - fontSize: 12, - height: 1.2, - fontWeight: FontWeight.w600, - color: colors.accent, - fontFeatures: const [ - FontFeature.tabularFigures(), - ], + const SizedBox(height: 2), + Text( + caption, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 12, + height: 1.2, + fontWeight: FontWeight.w600, + color: colors.accent, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), ), - ), - ], + ], + ), ), ), ), ), - ), ], ), ), diff --git a/lib/app/settings_controller.dart b/lib/app/settings_controller.dart index fdf6965..cec1d58 100644 --- a/lib/app/settings_controller.dart +++ b/lib/app/settings_controller.dart @@ -48,6 +48,7 @@ class AppSettingsController extends ChangeNotifier { final timetableHeightMode = ValueNotifier(TimetableHeightMode.normal); final deadlineCutoff = ValueNotifier(const DeadlineCutoffWindow()); final agendaLayoutDays = ValueNotifier(4); + final agendaEventInfoBar = ValueNotifier(true); Future? _paletteSettingsLoad; SystemPalette? _systemPalette; @@ -58,6 +59,7 @@ class AppSettingsController extends ChangeNotifier { bool _timetableHeightChosen = false; bool _deadlineCutoffChosen = false; bool _agendaLayoutDaysChosen = false; + bool _agendaEventInfoBarChosen = false; bool _developerMode = false; bool _widgetUseSystemPalette = false; bool _globalUseSystemPalette = false; @@ -177,6 +179,18 @@ class AppSettingsController extends ChangeNotifier { } catch (failure, stack) { AppDiagnostics.instance.report('读取 DDL 排版宽度', failure, stack); } + try { + final saved = await _preferences.getString('agendaEventInfoBar'); + if (!_agendaEventInfoBarChosen && !_disposed && saved != null) { + if (saved != 'true' && saved != 'false') { + throw const FormatException('日程信息栏设置必须是布尔值'); + } + agendaEventInfoBar.value = saved == 'true'; + _notify(); + } + } catch (failure, stack) { + AppDiagnostics.instance.report('读取日程信息栏设置', failure, stack); + } } Future setTheme(ThemeMode value) { @@ -414,6 +428,18 @@ class AppSettingsController extends ChangeNotifier { ); } + Future setAgendaEventInfoBar(bool value) { + if (_disposed) return Future.value(); + _agendaEventInfoBarChosen = true; + agendaEventInfoBar.value = value; + _notify(); + return _writePreference( + 'agendaEventInfoBar', + value ? 'true' : 'false', + '保存日程信息栏设置', + ); + } + Future _writePreference(String key, String value, String operation) { final previous = _preferenceWrites[key] ?? Future.value(); final write = previous.then((_) async { @@ -440,6 +466,7 @@ class AppSettingsController extends ChangeNotifier { timetableHeightMode.dispose(); deadlineCutoff.dispose(); agendaLayoutDays.dispose(); + agendaEventInfoBar.dispose(); super.dispose(); } } diff --git a/lib/app/ui/settings_screen.dart b/lib/app/ui/settings_screen.dart index 221e15b..53fe2c5 100644 --- a/lib/app/ui/settings_screen.dart +++ b/lib/app/ui/settings_screen.dart @@ -215,6 +215,18 @@ class SettingsScreen extends StatelessWidget { section(l10n.settingsLocalAgenda, [ AgendaLayoutDaysSettingsTile(settings: controller.settings), DeadlineCutoffSettingsTile(settings: controller.settings), + ValueListenableBuilder( + valueListenable: controller.settings.agendaEventInfoBar, + builder: (context, infoBar, _) => SwitchListTile( + key: const ValueKey('agenda-event-info-bar-settings'), + contentPadding: tilePadding, + secondary: const Icon(Icons.article_outlined), + title: Text(l10n.settingsAgendaEventInfoBar), + subtitle: Text(l10n.settingsAgendaEventInfoBarDescription), + value: infoBar, + onChanged: controller.settings.setAgendaEventInfoBar, + ), + ), ListTile( contentPadding: tilePadding, leading: Icon(Icons.delete_outline_rounded, color: scheme.error), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 132342b..ce407b1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1807,6 +1807,8 @@ "agendaLayoutDaysDescription": "Use one layout width for deadlines, start-only items, and the left side of adjusted deadlines, overriding old per-item tails. Painted tails keep a fixed screen width.", "agendaLayoutDaysUnit": "days", "agendaLayoutDaysRange": "Enter a whole number from 1 to 30", + "settingsAgendaEventInfoBar": "Event information bar", + "settingsAgendaEventInfoBarDescription": "Show the left information card for events and exams in the timeline. When off, its layout space is released; boundary lines, the filled interval, and deadlines are unaffected.", "releaseVersion": "{channel} {version}", "@releaseVersion": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 80cb480..47558e5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4440,6 +4440,18 @@ abstract class AppLocalizations { /// **'Enter a whole number from 1 to 30'** String get agendaLayoutDaysRange; + /// No description provided for @settingsAgendaEventInfoBar. + /// + /// In en, this message translates to: + /// **'Event information bar'** + String get settingsAgendaEventInfoBar; + + /// No description provided for @settingsAgendaEventInfoBarDescription. + /// + /// In en, this message translates to: + /// **'Show the left information card for events and exams in the timeline. When off, its layout space is released; boundary lines, the filled interval, and deadlines are unaffected.'** + String get settingsAgendaEventInfoBarDescription; + /// No description provided for @releaseVersion. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index da1a83d..ecb9f7d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2574,6 +2574,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get agendaLayoutDaysRange => 'Enter a whole number from 1 to 30'; + @override + String get settingsAgendaEventInfoBar => 'Event information bar'; + + @override + String get settingsAgendaEventInfoBarDescription => 'Show the left information card for events and exams in the timeline. When off, its layout space is released; boundary lines, the filled interval, and deadlines are unaffected.'; + @override String releaseVersion(String channel, String version) { return '$channel $version'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 1a8b116..92ce4a8 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2460,6 +2460,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get agendaLayoutDaysRange => '请输入 1–30 的整数'; + @override + String get settingsAgendaEventInfoBar => '日程与考试信息栏'; + + @override + String get settingsAgendaEventInfoBarDescription => '控制泳道图中普通日程和考试左侧的信息栏是否显示;关闭后不再预留排版空间,开始与结束标线及中间时段不受影响。DDL 不受此开关影响。'; + @override String releaseVersion(String channel, String version) { return '$channel $version'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index bb91587..45a1152 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -727,5 +727,7 @@ "agendaLayoutDaysDescription": "统一控制 DDL、仅开始时间项目及伪提前 DDL 左侧信息区的排版宽度,覆盖原单条拖尾设置;渲染仍保持屏幕定宽。", "agendaLayoutDaysUnit": "天", "agendaLayoutDaysRange": "请输入 1–30 的整数", + "settingsAgendaEventInfoBar": "日程与考试信息栏", + "settingsAgendaEventInfoBarDescription": "控制泳道图中普通日程和考试左侧的信息栏是否显示;关闭后不再预留排版空间,开始与结束标线及中间时段不受影响。DDL 不受此开关影响。", "releaseVersion": "{channel} {version}" } diff --git a/test/event_info_bar_test.dart b/test/event_info_bar_test.dart new file mode 100644 index 0000000..96e5d7e --- /dev/null +++ b/test/event_info_bar_test.dart @@ -0,0 +1,208 @@ +import 'package:bitree/agenda/model.dart'; +import 'package:bitree/agenda/timeline_layout.dart'; +import 'package:bitree/agenda/ui/agenda_timeline.dart'; +import 'package:bitree/agenda/ui/event_timeline_entry.dart'; +import 'package:bitree/app/settings_controller.dart'; +import 'package:bitree/app/ui/settings_screen.dart'; +import 'package:bitree/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/test_app.dart'; + +const _key = 'agendaEventInfoBar'; +final _date = DateTime.utc(2026, 11, 17); + +AppSettingsController _settings(MemoryPreferences preferences) => + AppSettingsController(preferences, coursePaletteLoader: () async => {}); + +AgendaEntry _timed(String id, int startHour, int endHour) => AgendaEntry( + id: id, + title: id, + kind: AgendaKind.event, + startsAt: _date.add(Duration(hours: startHour)), + endsAt: _date.add(Duration(hours: endHour)), +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Information bar defaults to visible without writing preferences', () async { + final preferences = MemoryPreferences(); + final settings = _settings(preferences); + addTearDown(settings.dispose); + await settings.initialize(); + expect(settings.agendaEventInfoBar.value, isTrue); + expect(preferences.value(_key), isNull); + }); + + test('Hidden bar restores, publishes immediately and survives restart', () async { + final preferences = MemoryPreferences({_key: 'false'}); + final first = _settings(preferences); + addTearDown(first.dispose); + await first.initialize(); + expect(first.agendaEventInfoBar.value, isFalse); + final writing = first.setAgendaEventInfoBar(true); + expect(first.agendaEventInfoBar.value, isTrue); + await writing; + expect(preferences.value(_key), 'true'); + final restarted = _settings(preferences); + addTearDown(restarted.dispose); + await restarted.initialize(); + expect(restarted.agendaEventInfoBar.value, isTrue); + }); + + for (final invalid in ['', 'yes', 'TRUE', '1']) { + test('Invalid saved visibility falls back to visible: $invalid', () async { + final settings = _settings(MemoryPreferences({_key: invalid})); + addTearDown(settings.dispose); + await settings.initialize(); + expect(settings.agendaEventInfoBar.value, isTrue); + }); + } + + test('Hidden information releases the lane lead and the painted lead', () { + final event = _timed('event', 11, 13); + final deadline = AgendaEntry( + id: 'ddl', + title: 'ddl', + kind: AgendaKind.deadline, + startsAt: _date.add(const Duration(days: 1, hours: 12)), + ); + final fixedCardSpan = Duration( + microseconds: (Duration.microsecondsPerDay * 3.75).round(), + ); + for (final reserve in [true, false]) { + final placements = layoutAgendaTimeline( + [event, deadline], + minimumEventSpan: const Duration(minutes: 1), + reserveEventInformation: reserve, + ); + final byId = {for (final p in placements) p.entry.id: p}; + final painted = byId['event']!; + expect( + painted.start, + reserve + ? event.startsAt.subtract(const Duration(days: 4)) + : event.startsAt, + ); + expect( + painted.paintStart, + reserve ? lessThan(event.startsAt) : event.startsAt, + ); + expect(painted.end, event.endsAt); + expect(painted.paintEnd, event.endsAt); + // Deadlines keep their information lead regardless of this setting. + expect(byId['ddl']!.start, deadline.dueAt.subtract(const Duration(days: 4))); + expect(byId['ddl']!.paintStart, deadline.dueAt.subtract(fixedCardSpan)); + } + }); + + testWidgets('The switch hides event text, releases its lane lead, and restores', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final entries = [ + _timed('morning', 11, 13), + _timed('afternoon', 13, 15), + AgendaEntry( + id: 'ddl', + title: 'ddl', + kind: AgendaKind.deadline, + startsAt: _date.add(const Duration(days: 1, hours: 12)), + ), + ]; + final app = TestAppController(entries: entries); + await app.initialize(); + await tester.pumpWidget(MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold(body: AgendaTimeline( + controller: app, + entries: entries, + now: _date.add(const Duration(hours: 10)), + )), + )); + await tester.pumpAndSettle(); + Finder card(String id) => find.byKey(ValueKey(('agenda-timeline-entry', id))); + Finder event(String id) => find.byWidgetPredicate( + (widget) => widget is EventTimelineEntry && widget.entry.id == id, + ); + Finder bar(String id) => find.byKey(ValueKey(('agenda-timeline-bar', id))); + expect(bar('morning'), findsOneWidget); + final shown = tester.widget(event('morning')); + expect(shown.showInformation, isTrue); + expect( + shown.geometry.startX - shown.geometry.informationLeft, + closeTo(48 * 3.75, .001), + ); + // The information lead makes back-to-back intervals overlap in layout. + expect( + tester.getRect(card('morning')).top, + isNot(closeTo(tester.getRect(card('afternoon')).top, .001)), + ); + + await app.settings.setAgendaEventInfoBar(false); + await tester.pumpAndSettle(); + expect(bar('morning'), findsNothing); + expect(bar('afternoon'), findsNothing); + final hidden = tester.widget(event('morning')); + expect(hidden.showInformation, isFalse); + expect(hidden.geometry.informationLeft, closeTo(hidden.geometry.startX, .001)); + // Without the lead the adjacent intervals share one lane. + expect( + tester.getRect(card('morning')).top, + closeTo(tester.getRect(card('afternoon')).top, .001), + ); + // Deadlines keep their own information cards. + expect(bar('ddl'), findsOneWidget); + + await app.settings.setAgendaEventInfoBar(true); + await tester.pumpAndSettle(); + expect(bar('morning'), findsOneWidget); + final restored = tester.widget(event('morning')); + expect( + restored.geometry.startX - restored.geometry.informationLeft, + closeTo(48 * 3.75, .001), + ); + expect( + tester.getRect(card('morning')).top, + isNot(closeTo(tester.getRect(card('afternoon')).top, .001)), + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('Settings switches the event information bar for events and exams', ( + tester, + ) async { + final preferences = MemoryPreferences(); + final controller = TestAppController(preferences: preferences); + await controller.initialize(); + await tester.pumpWidget(MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold(body: SettingsScreen( + controller: controller, + checkingForUpdates: false, + onCheckForUpdates: () {}, + )), + )); + await tester.pump(); + final switcher = find.byKey(const ValueKey('agenda-event-info-bar-settings')); + await tester.ensureVisible(switcher); + await tester.pumpAndSettle(); + expect(tester.widget(switcher).value, isTrue); + await tester.tap(switcher); + await tester.pumpAndSettle(); + expect(controller.settings.agendaEventInfoBar.value, isFalse); + expect(preferences.value(_key), 'false'); + await tester.tap(switcher); + await tester.pumpAndSettle(); + expect(controller.settings.agendaEventInfoBar.value, isTrue); + expect(preferences.value(_key), 'true'); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); +} diff --git a/test/event_timeline_style_test.dart b/test/event_timeline_style_test.dart index d9f5b30..bfcce05 100644 --- a/test/event_timeline_style_test.dart +++ b/test/event_timeline_style_test.dart @@ -79,9 +79,17 @@ void main() { final data = await _pixels(tester, key); int channel(int x, int y, int component) => data.getUint8((y * 120 + x) * 4 + component); - expect(channel(10, 4, 3), closeTo(255 * completionAlpha * .5, 2)); - expect(channel(26, 4, 3), closeTo(255 * completionAlpha * .25, 2)); + // Bands alternate between the solid fill and its half alpha. + expect(channel(10, 4, 3), closeTo(255 * completionAlpha, 2)); + expect(channel(26, 4, 3), closeTo(255 * completionAlpha * .5, 2)); expect(channel(6, 8, 3), channel(10, 4, 3)); + if (!done) { + // The inner border is the accent (red-led), unlike the fill-colored + // stripes (blue-led). The 1.5px stroke never fully covers a pixel, so + // compare premultiplied hue instead of an exact alpha. + expect(channel(0, 20, 0), greaterThan(channel(0, 20, 2))); + } + expect(channel(10, 4, 0), lessThan(channel(10, 4, 2))); for (final (x, y) in [(10, 4), (26, 4), (90, 20)]) { final alpha = channel(x, y, 3) / 255; expect(channel(x, y, 0), closeTo(0x33 * alpha, 2)); @@ -107,6 +115,45 @@ void main() { }); } + testWidgets('Hidden information bars keep the interval and both lines only', ( + tester, + ) async { + final key = GlobalKey(); + await tester.pumpWidget(MaterialApp( + home: Center(child: RepaintBoundary( + key: key, + child: SizedBox(width: 120, height: 40, child: CustomPaint( + painter: const EventTimelinePainter( + geometry: EventTimelineGeometry( + informationLeft: 0, startX: 80, endX: 110, + ), + fill: Color(0xFF336699), + accent: Color(0xFF995522), + showInformation: false, + ), + )), + )), + )); + await tester.pumpAndSettle(); + final data = await _pixels(tester, key); + int channel(int x, int y, int component) => + data.getUint8((y * 120 + x) * 4 + component); + // No stripes and no inner border where the information card used to be. + for (final (x, y) in [(0, 20), (6, 8), (10, 4), (26, 4), (70, 20)]) { + expect(channel(x, y, 3), 0, reason: 'info pixel $x,$y'); + } + // The interval and both boundary lines still paint unchanged. + expect(channel(90, 20, 3), 255); + expect(channel(90, 8, 3), channel(100, 25, 3)); + for (final x in [79, 109]) { + expect(channel(x, 20, 0), closeTo(0x99, 2)); + expect(channel(x, 20, 1), closeTo(0x55, 2)); + expect(channel(x, 20, 2), closeTo(0x22, 2)); + } + expect(channel(111, 20, 3), 0); + await tester.pumpWidget(const SizedBox()); + }); + for (final exam in [false, true]) { testWidgets('Event and exam information stays fixed while the span zooms: $exam', ( tester, From d769f89d8b79192a043ccbefd04822b215110770 Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Wed, 11 Nov 2026 21:34:21 +0800 Subject: [PATCH 16/24] feat(agenda): band event intervals and keep the hidden start line Paint the real event/exam interval as 45-degree two-color bands that alternate the whole fill and the accent, the fill band first at the start line, both only faded by the completion factor. This reuses the shared stripe painter through a new optional secondColor. A hidden information card previously clipped the start indicator, which is drawn left of its time, against the surface now beginning exactly at the real start. The painted surface keeps one line width of lead in the hidden state so the start line stays visible at its true position; lane reservation stays at the real interval. --- docs/architecture.md | 2 +- docs/deadline-cutoff.md | 19 +++++++++------- lib/agenda/ui/adjusted_deadline_painter.dart | 18 +++++++++++---- lib/agenda/ui/agenda_timeline.dart | 6 ++++- lib/agenda/ui/event_timeline_entry.dart | 24 +++++++++++--------- test/event_info_bar_test.dart | 5 +++- test/event_timeline_style_test.dart | 22 ++++++++++-------- 7 files changed, 61 insertions(+), 35 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index f297fa9..85201c8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,7 +86,7 @@ USTC 风控采集失败时遵循学校登录页的协议:仅在风控请求出 `AgendaTimelineEntry` 只负责条目的外观和交互,不处理坐标或泳道。日程与考试在标题下常驻真实起止时间,跨天时分两行标出日期;全天日程显示日期范围。DDL 保留课程/来源,并另列截止时间。长按或鼠标悬停显示包含开始、结束或截止日期、课程及地点的分行预览;点按沿用详情抽屉。卡片最小高度为 56 逻辑像素,并随字体放大,确保完整时间与来源信息有独立空间。 -DDL 主体右端为有效 `dueAt`,从左侧渐隐;仅有开放时间的项目从左向右淡出。主体屏幕定宽,不因邻卡裁短信息区。每日伪提前窗口支持关闭、跨日、两端包含及起止同点;原始截止不写回覆盖。命中窗口时,有效与真实截止之间绘制随时间缩放的宽斜纹矩形,真实标线使用变淡色并始终最后绘制,包括重叠情形。普通日程和考试真实时段为两条实线之间的无渐变填充,左信息区使用实填充色与其半透明交替的斜纹及向内半线宽高亮色边框;设置中的“日程与考试信息栏”开关(默认开启)可隐藏日程与考试的左信息栏并同时取消其左侧预留,占位改为 `[真实开始, 真实结束]`,DDL、仅开始项目与真实时段绘制不受影响。各区域完成透明度都乘原 alpha,已半透明区域再衰减,详见 [布局与伪截止规则](deadline-cutoff.md)。全天 DDL 的真实截止仍为对应日期次日零点,只有有效截止可派生提前。 +DDL 主体右端为有效 `dueAt`,从左侧渐隐;仅有开放时间的项目从左向右淡出。主体屏幕定宽,不因邻卡裁短信息区。每日伪提前窗口支持关闭、跨日、两端包含及起止同点;原始截止不写回覆盖。命中窗口时,有效与真实截止之间绘制随时间缩放的宽斜纹矩形,真实标线使用变淡色并始终最后绘制,包括重叠情形。普通日程和考试真实时段为两条实线之间 45° 双色宽斜纹填充(实填充色与高亮色两带全不透明交替,首带为实色),左信息区使用实填充色与其半透明交替的斜纹及向内半线宽高亮色边框;设置中的“日程与考试信息栏”开关(默认开启)可隐藏日程与考试的左信息栏并同时取消其左侧预留,占位改为 `[真实开始, 真实结束]`,隐藏时绘制面额外保留一个线宽使开始标线可见,DDL、仅开始项目与真实时段绘制不受影响。各区域完成透明度都乘原 alpha,已半透明区域再衰减,详见 [布局与伪截止规则](deadline-cutoff.md)。全天 DDL 的真实截止仍为对应日期次日零点,只有有效截止可派生提前。 时间轴以视口中心时间和连续缩放比例计算坐标,不创建覆盖历史与未来的巨大滚动画布。默认一天一格,最多一小时一格;基准格宽为 48~84 逻辑像素。平移没有预设日期边界,只绘制可见区域附近的网格和条目。顶部工具栏只保留单行并排的「今天」按钮与自适应宽度的缩放滑条,不显示当前日期和常驻的「时间/格」文本,缩放比例仅保留无障碍语义描述。表头基准高度为 24 逻辑像素:不显示小时时,第一行显示日期、第二行居中显示星期;显示小时时,第一行将「日期 星期」作为一个整体居中,第二行显示小时。标签宽度测量包含完整星期与中间空格,固定在对应时间格的中上方,以完整文字绘制并在视口边缘自然裁切,不贴靠屏幕边缘或变成省略号。首个日程条距表头分隔线 12 逻辑像素,与日程条之间的轨道间距一致。工具栏不再包含问号帮助图标及其提示。当前时间线单独在前景绘制,覆盖日程条但不拦截交互,也不绘制圆点。条目单击复用日程详情弹层,编辑、删除、同步后由功能控制器通知刷新。 diff --git a/docs/deadline-cutoff.md b/docs/deadline-cutoff.md index 18b0e8f..6065ce7 100644 --- a/docs/deadline-cutoff.md +++ b/docs/deadline-cutoff.md @@ -48,14 +48,17 @@ JSON 设置保存;关闭仍保留所选时间。时间选择页常驻原生 Ma 普通日程和考试的左侧信息区同样使用全局 `D` 天预留及 `D − 0.25` 基线天 的屏幕定宽,整体占位为 `[真实开始 − D 天, 真实结束]`。真实时段由开始、 -结束两条 3px 实线和无渐变填充组成,按实际时距缩放;左侧是 45° 斜纹 -(两带交替使用实填充色与其 0.5 半透明)及向内绘制的 1.5px 高亮色 -(accent)边框。完成时在原颜色透明度上再乘 0.5,不能把半透明部分覆盖成 -固定透明度。两条时间线重合时结束线最后绘制。所有区域保持视口裁剪, -日期与真实时距不因左侧信息区预留而改变。设置中的“日程与考试信息栏” -开关(默认开启)可隐藏普通日程和考试的信息栏文字、斜纹与边框,同时 -取消其左侧排版预留(占位改为 `[真实开始, 真实结束]`);开始/结束标线、 -中间填充和点击区域不受影响,DDL 与仅开始时间项目不受该开关影响。 +结束两条 3px 实线框住,其间是 45° 双色宽斜纹(实填充色与高亮色两带 +全不透明交替,自开始标线起首带为实色),按实际时距缩放;左侧同样是 +45° 斜纹(两带交替使用实填充色与其 0.5 半透明)及向内绘制的 1.5px +高亮色(accent)边框。完成时在原颜色透明度上再乘 0.5,不能把半透明 +部分覆盖成固定透明度。两条时间线重合时结束线最后绘制。所有区域保持 +视口裁剪,日期与真实时距不因左侧信息区预留而改变。设置中的“日程与 +考试信息栏”开关(默认开启)可隐藏普通日程和考试的信息栏文字、斜纹与 +边框,同时取消其左侧排版预留(占位改为 `[真实开始, 真实结束]`); +隐藏时绘制面仍向左额外保留一个线宽,开始标线按真实位置保持可见, +结束标线、时段斜纹和点击区域不受影响,DDL 与仅开始时间项目不受该 +开关影响。 完成、撤销、考试自动一次和旧数据迁移规则见 [日程完成状态](agenda-completion.md)。 diff --git a/lib/agenda/ui/adjusted_deadline_painter.dart b/lib/agenda/ui/adjusted_deadline_painter.dart index 2a1d7d6..8f45c7d 100644 --- a/lib/agenda/ui/adjusted_deadline_painter.dart +++ b/lib/agenda/ui/adjusted_deadline_painter.dart @@ -23,6 +23,7 @@ class AdjustedDeadlineExtensionPainter extends CustomPainter { required this.effectiveX, required this.realX, required this.color, + this.secondColor, this.stripeWidth = 16, this.highOpacity = 0.32, this.lowOpacity = 0.16, @@ -31,6 +32,11 @@ class AdjustedDeadlineExtensionPainter extends CustomPainter { final double effectiveX; final double realX; final Color color; + + /// When set, bands alternate whole colors instead of one color's two alpha + /// levels: even bands paint [color], odd bands paint [secondColor], both at + /// their own full alpha. The first band starts at [effectiveX]. + final Color? secondColor; final double stripeWidth; final double highOpacity; final double lowOpacity; @@ -54,12 +60,15 @@ class AdjustedDeadlineExtensionPainter extends CustomPainter { ..lineTo(x + stripeWidth - size.height, size.height) ..lineTo(x - size.height, size.height) ..close(); + final second = secondColor; canvas.drawPath( path, - Paint() - ..color = color.withValues( - alpha: color.a * (index.isEven ? highOpacity : lowOpacity), - ), + second == null + ? Paint() + ..color = color.withValues( + alpha: color.a * (index.isEven ? highOpacity : lowOpacity), + ) + : Paint()..color = index.isEven ? color : second, ); } canvas.restore(); @@ -70,6 +79,7 @@ class AdjustedDeadlineExtensionPainter extends CustomPainter { oldDelegate.effectiveX != effectiveX || oldDelegate.realX != realX || oldDelegate.color != color || + oldDelegate.secondColor != secondColor || oldDelegate.stripeWidth != stripeWidth || oldDelegate.highOpacity != highOpacity || oldDelegate.lowOpacity != lowOpacity; diff --git a/lib/agenda/ui/agenda_timeline.dart b/lib/agenda/ui/agenda_timeline.dart index 0b91b02..29bd9fb 100644 --- a/lib/agenda/ui/agenda_timeline.dart +++ b/lib/agenda/ui/agenda_timeline.dart @@ -790,7 +790,11 @@ class _AgendaTimelineState extends State .agendaEventInfoBar.value; final start = x(entry.startsAt); final end = x(entry.endsAt!); - final informationLeft = showInformation ? start - deadlineWidth : start; + // Hidden, the surface still extends one line width left of the real + // start so the start indicator stays painted at its true position. + final informationLeft = showInformation + ? start - deadlineWidth + : start - eventTimelineLineWidth; final left = math.max(0.0, informationLeft); final right = math.min(width, end); return Positioned( diff --git a/lib/agenda/ui/event_timeline_entry.dart b/lib/agenda/ui/event_timeline_entry.dart index b88325a..6f2bd41 100644 --- a/lib/agenda/ui/event_timeline_entry.dart +++ b/lib/agenda/ui/event_timeline_entry.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart'; import '../../core/time.dart'; @@ -22,6 +20,10 @@ class EventTimelineGeometry { final double endX; } +/// Width of the start/end indicator lines; the hidden card's painted surface +/// extends by this much left of the real start so the indicator stays visible. +const double eventTimelineLineWidth = 3; + /// Event information has the same fixed screen width as a DDL tail. The actual /// event interval retains its time-scaled width, independently of the text. class EventTimelinePainter extends CustomPainter { @@ -29,7 +31,7 @@ class EventTimelinePainter extends CustomPainter { required this.geometry, required this.fill, required this.accent, - this.lineWidth = 3, + this.lineWidth = eventTimelineLineWidth, this.showInformation = true, }); @@ -68,14 +70,14 @@ class EventTimelinePainter extends CustomPainter { ); } } - final intervalLeft = math.max(0.0, geometry.startX); - final intervalRight = math.min(size.width, geometry.endX); - if (intervalRight > intervalLeft) { - canvas.drawRect( - Rect.fromLTRB(intervalLeft, 0, intervalRight, size.height), - Paint()..color = fill, - ); - } + // The real interval alternates whole fill and accent bands, the first + // band being the fill anchored at the start line. + AdjustedDeadlineExtensionPainter( + effectiveX: geometry.startX, + realX: geometry.endX, + color: fill, + secondColor: accent, + ).paint(canvas, size); for (final x in [geometry.startX, geometry.endX]) { canvas.drawRect( Rect.fromLTRB(x - lineWidth, 0, x, size.height), diff --git a/test/event_info_bar_test.dart b/test/event_info_bar_test.dart index 96e5d7e..d31f94a 100644 --- a/test/event_info_bar_test.dart +++ b/test/event_info_bar_test.dart @@ -150,7 +150,10 @@ void main() { expect(bar('afternoon'), findsNothing); final hidden = tester.widget(event('morning')); expect(hidden.showInformation, isFalse); - expect(hidden.geometry.informationLeft, closeTo(hidden.geometry.startX, .001)); + // The painted surface still keeps exactly one line width left of the + // real start so the start indicator stays visible. + expect(hidden.geometry.informationLeft, closeTo(0, .001)); + expect(hidden.geometry.startX, closeTo(3, .001)); // Without the lead the adjacent intervals share one lane. expect( tester.getRect(card('morning')).top, diff --git a/test/event_timeline_style_test.dart b/test/event_timeline_style_test.dart index bfcce05..259273a 100644 --- a/test/event_timeline_style_test.dart +++ b/test/event_timeline_style_test.dart @@ -90,15 +90,18 @@ void main() { expect(channel(0, 20, 0), greaterThan(channel(0, 20, 2))); } expect(channel(10, 4, 0), lessThan(channel(10, 4, 2))); - for (final (x, y) in [(10, 4), (26, 4), (90, 20)]) { + for (final (x, y) in [(10, 4), (26, 4)]) { final alpha = channel(x, y, 3) / 255; expect(channel(x, y, 0), closeTo(0x33 * alpha, 2)); expect(channel(x, y, 1), closeTo(0x66 * alpha, 2)); expect(channel(x, y, 2), closeTo(0x99 * alpha, 2)); } - expect(channel(90, 20, 3), closeTo(255 * completionAlpha, 2)); - // The real interval is solid: no vertical or horizontal fade. - expect(channel(90, 8, 3), channel(100, 25, 3)); + // The real interval alternates whole bands, fill first then the accent. + // (88, 20) and (100, 25) sit fully inside one band each. + expect(channel(88, 20, 3), closeTo(255 * completionAlpha, 2)); + expect(channel(88, 20, 0), greaterThan(channel(88, 20, 2))); + expect(channel(100, 25, 3), closeTo(255 * completionAlpha, 2)); + expect(channel(100, 25, 2), greaterThan(channel(100, 25, 0))); // Both temporal boundaries use the accent, independently of duration. for (final x in [79, 109]) { if (!done) { @@ -106,8 +109,8 @@ void main() { expect(channel(x, 20, 1), closeTo(0x55, 2)); expect(channel(x, 20, 2), closeTo(0x22, 2)); } else { - // Translucent marker paint composites over the interval/inner border. - expect(channel(x, 20, 0), greaterThan(channel(90, 20, 0))); + // Translucent marker paint composites over the interval bands. + expect(channel(x, 20, 0), greaterThan(channel(88, 20, 0))); } } expect(channel(111, 20, 3), 0); @@ -142,9 +145,10 @@ void main() { for (final (x, y) in [(0, 20), (6, 8), (10, 4), (26, 4), (70, 20)]) { expect(channel(x, y, 3), 0, reason: 'info pixel $x,$y'); } - // The interval and both boundary lines still paint unchanged. - expect(channel(90, 20, 3), 255); - expect(channel(90, 8, 3), channel(100, 25, 3)); + // The banded interval and both boundary lines still paint unchanged. + expect(channel(88, 20, 3), 255); + expect(channel(88, 20, 0), greaterThan(channel(88, 20, 2))); + expect(channel(100, 25, 2), greaterThan(channel(100, 25, 0))); for (final x in [79, 109]) { expect(channel(x, 20, 0), closeTo(0x99, 2)); expect(channel(x, 20, 1), closeTo(0x55, 2)); From 788caacc7af9647896964d3ac7cde41719581bc5 Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Wed, 11 Nov 2026 21:35:41 +0800 Subject: [PATCH 17/24] test(settings): scroll the lazy settings list before the info bar switch The settings ListView builds children lazily, so the switch row did not exist for ensureVisible yet. --- test/event_info_bar_test.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/event_info_bar_test.dart b/test/event_info_bar_test.dart index d31f94a..f1d818e 100644 --- a/test/event_info_bar_test.dart +++ b/test/event_info_bar_test.dart @@ -194,7 +194,12 @@ void main() { )); await tester.pump(); final switcher = find.byKey(const ValueKey('agenda-event-info-bar-settings')); - await tester.ensureVisible(switcher); + // The settings list builds lazily, so scroll until the row exists. + await tester.scrollUntilVisible( + switcher, + 200, + scrollable: find.byType(Scrollable).first, + ); await tester.pumpAndSettle(); expect(tester.widget(switcher).value, isTrue); await tester.tap(switcher); From a421043899e810b46bd556de29872eb393737fca Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Wed, 11 Nov 2026 21:43:21 +0800 Subject: [PATCH 18/24] fix(agenda): compute band color before the cascade A cascade expression is not a valid operand of a conditional expression; the release kernel compiler rejects it. Choose the band color into a local first. --- lib/agenda/ui/adjusted_deadline_painter.dart | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/agenda/ui/adjusted_deadline_painter.dart b/lib/agenda/ui/adjusted_deadline_painter.dart index 8f45c7d..303b058 100644 --- a/lib/agenda/ui/adjusted_deadline_painter.dart +++ b/lib/agenda/ui/adjusted_deadline_painter.dart @@ -61,15 +61,14 @@ class AdjustedDeadlineExtensionPainter extends CustomPainter { ..lineTo(x - size.height, size.height) ..close(); final second = secondColor; - canvas.drawPath( - path, - second == null - ? Paint() - ..color = color.withValues( - alpha: color.a * (index.isEven ? highOpacity : lowOpacity), - ) - : Paint()..color = index.isEven ? color : second, - ); + final band = second == null + ? color.withValues( + alpha: color.a * (index.isEven ? highOpacity : lowOpacity), + ) + : index.isEven + ? color + : second; + canvas.drawPath(path, Paint()..color = band); } canvas.restore(); } From 188d84f955291260e0e1db39ea4314b9ca2e4fdd Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Wed, 11 Nov 2026 21:59:10 +0800 Subject: [PATCH 19/24] test(agenda): compare band paint starts without DateTime < The lessThan matcher needs the < operator, which DateTime does not implement; assert the exact projected start instead. --- test/event_info_bar_test.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/event_info_bar_test.dart b/test/event_info_bar_test.dart index f1d818e..903e9d0 100644 --- a/test/event_info_bar_test.dart +++ b/test/event_info_bar_test.dart @@ -88,7 +88,9 @@ void main() { ); expect( painted.paintStart, - reserve ? lessThan(event.startsAt) : event.startsAt, + reserve + ? event.startsAt.subtract(fixedCardSpan) + : event.startsAt, ); expect(painted.end, event.endsAt); expect(painted.paintEnd, event.endsAt); From 7280fb5bbe72faf766663c616e9d3174b79da392 Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Fri, 13 Nov 2026 16:48:56 +0800 Subject: [PATCH 20/24] refactor(agenda): remove the unified exam/event completion model Erases the exam/event overdue-and-completion state model introduced by 90f0f69: the persisted completion fields, the one-shot exam auto-completion clock, the completion-aware write paths, widget derivation, tests and docs. Exams and events fall back to the pre-feature elapsed semantics where past entries fade through isFinished; the DDL completion path is untouched. --- .../kotlin/dev/so1ve/bitree/WidgetAgenda.kt | 28 +- .../dev/so1ve/bitree/WidgetAgendaTest.kt | 31 +- docs/agenda-completion.md | 67 -- docs/architecture.md | 6 +- docs/deadline-cutoff.md | 5 +- lib/academics/ui/exams_screen.dart | 6 +- lib/agenda/controller.dart | 203 +----- lib/agenda/model.dart | 40 +- lib/agenda/ui/agenda_detail.dart | 81 ++- lib/agenda/ui/agenda_editor.dart | 5 - lib/agenda/ui/agenda_list_tile.dart | 55 +- lib/agenda/ui/agenda_operations.dart | 2 +- lib/agenda/ui/agenda_reminder.dart | 2 +- lib/agenda/ui/event_timeline_entry.dart | 2 +- lib/app/controller.dart | 27 +- lib/l10n/formats.dart | 11 +- test/agenda_completion_test.dart | 596 ------------------ test/agenda_detail_test.dart | 70 +- test/agenda_swipe_test.dart | 69 -- test/calendar_block_test.dart | 12 +- test/deadline_cutoff_badge_test.dart | 2 +- test/deadline_cutoff_test.dart | 4 +- test/exam_completion_filter_test.dart | 86 --- test/storage_compatibility_test.dart | 119 ---- test/support/test_app.dart | 1 - 25 files changed, 128 insertions(+), 1402 deletions(-) delete mode 100644 docs/agenda-completion.md delete mode 100644 test/agenda_completion_test.dart delete mode 100644 test/exam_completion_filter_test.dart diff --git a/android/app/src/main/kotlin/dev/so1ve/bitree/WidgetAgenda.kt b/android/app/src/main/kotlin/dev/so1ve/bitree/WidgetAgenda.kt index 03b9a50..fad05fb 100644 --- a/android/app/src/main/kotlin/dev/so1ve/bitree/WidgetAgenda.kt +++ b/android/app/src/main/kotlin/dev/so1ve/bitree/WidgetAgenda.kt @@ -84,24 +84,15 @@ internal fun readWidgetContent( val todayHeading = "今天${weekdays[weekday(today) - 1]}" try { - val entries = readCourseEntries(context, listOf(today, tomorrow)) + readAgendaEntries(context, now.timeInMillis) + val entries = readCourseEntries(context, listOf(today, tomorrow)) + readAgendaEntries(context) fun entriesOn(date: Calendar) = entries.filter { it.occursOn(date) } .sortedWith(compareBy({ !it.allDay }, { it.startsAt }, { it.kind }, { it.title })) val todayEntries = entriesOn(today) val minute = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE) - val remaining = todayEntries.filter { - when (it.kind) { - WidgetEntryKind.EVENT, WidgetEntryKind.EXAM -> true - else -> it.endsAt > now.timeInMillis - } - } + val remaining = todayEntries.filter { it.endsAt > now.timeInMillis } for (entry in remaining) { - // Overdue agenda entries remain visible; past boundaries must never - // create an immediate widget-update loop. Courses keep the old rule. - if (entry.endsAt > now.timeInMillis && entry.endsAt < nextUpdate) { - nextUpdate = entry.endsAt - } + if (entry.endsAt < nextUpdate) nextUpdate = entry.endsAt } val showTomorrow = minute >= 21 * 60 && remaining.isEmpty() val displayed = if (showTomorrow) entriesOn(tomorrow) else remaining @@ -163,7 +154,7 @@ private fun readCourseEntries(context: Context, dates: List): List { +private fun readAgendaEntries(context: Context): List { val file = File(context.filesDir, "agenda.json") if (!file.exists()) return emptyList() @@ -174,7 +165,7 @@ private fun readAgendaEntries(context: Context, now: Long): List { return (0 until rows.length()).map { rows.getJSONObject(it) } .filter { !it.getBoolean("done") && !it.optBoolean("deleted") } - .mapNotNull { row -> + .map { row -> val kind = WidgetEntryKind.valueOf(row.getString("kind").uppercase(Locale.ROOT)) val start = timestamp(row.getString("startsAt")) val allDay = row.getBoolean("allDay") @@ -183,15 +174,6 @@ private fun readAgendaEntries(context: Context, now: Long): List { allDay -> dayStart(start).apply { add(Calendar.DATE, 1) }.timeInMillis else -> start } - // Read-only fallback while Flutter is stopped. Only Dart persists - // the one-shot marker; reopening an exam keeps it visible as overdue. - if (kind == WidgetEntryKind.EXAM && - !row.optBoolean("examAutoCompletionHandled") && end <= now - ) return@mapNotNull null - // Match the one-time legacy event migration without writing JSON. - if (kind == WidgetEntryKind.EVENT && - row.optInt("completionSchemaVersion", 0) < 1 && end <= now - ) return@mapNotNull null WidgetEntry( title = row.getString("title"), kind = kind, diff --git a/android/app/src/test/kotlin/dev/so1ve/bitree/WidgetAgendaTest.kt b/android/app/src/test/kotlin/dev/so1ve/bitree/WidgetAgendaTest.kt index 0df9543..b44d9ac 100644 --- a/android/app/src/test/kotlin/dev/so1ve/bitree/WidgetAgendaTest.kt +++ b/android/app/src/test/kotlin/dev/so1ve/bitree/WidgetAgendaTest.kt @@ -91,28 +91,7 @@ class WidgetAgendaTest { } @Test - fun versionedAgendaRetainsOverdueEventsAndReopenedExamsWithoutWriting() { - val now = timestamp("2026-11-09T12:00:00.000Z") - val entries = JSONArray() - .put(timed("逾期日程", "event", 1)) - .put(timed("已撤销考试", "exam", 1).put("examAutoCompletionHandled", true)) - .put(timed("尚未补账考试", "exam", 1)) - .put(timed("旧历史日程", "event", 0)) - .put(timed("已完成日程", "event", 1).put("done", true)) - .put(timed("已删除日程", "event", 1).put("deleted", true)) - val original = JSONObject().put("schemaVersion", 2) - .put("entries", entries).toString() - agendaFile.writeText(original) - - val content = readWidgetContent(context, now) - - assertEquals(setOf("逾期日程", "已撤销考试"), content.entries.map { it.title }.toSet()) - assertTrue(content.nextUpdate > now) - assertEquals(original, agendaFile.readText()) - } - - @Test - fun overdueAgendaDoesNotChangeCourseFilteringOrScheduleRefreshesInThePast() { + fun endedAgendaEntriesStayHiddenAndCoursesStillDriveNextUpdate() { val now = timestamp("2026-11-09T12:00:00.000Z") val courses = JSONArray() .put(course("已下课", 1)) @@ -126,18 +105,18 @@ class WidgetAgendaTest { ) agendaFile.writeText( JSONObject().put("schemaVersion", 2) - .put("entries", JSONArray().put(timed("逾期日程", "event", 1))) + .put("entries", JSONArray().put(timed("已结束日程", "event"))) .toString(), ) val content = readWidgetContent(context, now) - assertEquals(listOf("逾期日程", "下午课"), content.entries.map { it.title }) + assertEquals(listOf("下午课"), content.entries.map { it.title }) assertEquals(timestamp("2026-11-09T14:05:00.000Z"), content.nextUpdate) assertTrue(content.nextUpdate > now) } - private fun timed(title: String, kind: String, version: Int): JSONObject = JSONObject() + private fun timed(title: String, kind: String): JSONObject = JSONObject() .put("id", title) .put("title", title) .put("kind", kind) @@ -147,8 +126,6 @@ class WidgetAgendaTest { .put("location", "") .put("done", false) .put("deleted", false) - .put("completionSchemaVersion", version) - .put("examAutoCompletionHandled", false) private fun course(name: String, section: Int): JSONObject = JSONObject() .put("name", name) diff --git a/docs/agenda-completion.md b/docs/agenda-completion.md deleted file mode 100644 index 3672055..0000000 --- a/docs/agenda-completion.md +++ /dev/null @@ -1,67 +0,0 @@ -# 日程完成与考试自动一次 - -## 完成和时间结束分离 - -`done` 表示真正的完成选择;`isFinished(now)` 保持旧调用接口但统一返回 -`done`,不再用时间经过代替完成。`hasEnded(now)` 单独表示时间边界已到。 - -- DDL 沿用原有完成规则;逾期使用有效截止(含伪提前),仍是严格晚于截止。 -- 仅开始时间的 DDL 可以完成,但没有截止时间,永不逾期。 -- 普通日程不会自动完成;到真实结束时仍未完成即为逾期。 -- 考试到真实结束时自动完成一次。用户手动完成或撤销也会消费这次自动机会, - 因而提前手动完成再撤销也不会在结束时覆盖人工选择。 -- 考试自动完成后撤销,保持未完成并在真实结束后显示逾期;构建、重启、 - 同 ID 同步和编辑不会再次自动完成。 - -伪提前只用于 DDL,不移动普通日程或考试的真实时间。 - -## 持久化与迁移 - -新增 `examAutoCompletionHandled`(默认 false)记忆自动机会是否消费, -`completionSchemaVersion`(当前 1)标识一次性历史迁移。新建日程直接使用 -当前版本;旧 JSON 缺字段时读取为版本 0,解析本身不调用当前时钟。 - -控制器在加载后的可控时钟处迁移并原子保存:旧已结束普通日程标为完成以 -保留原“已结束”历史效果;旧未来普通日程只升级版本,不自动完成。旧已完成 -考试消费自动机会;未处理的到期考试原子写入 `done=true` 和 handled=true。 -版本写回后,撤销不会被下一次加载当作旧数据重新完成。 - -自动与人工操作共用串行队列。人工操作使用 ID 和目标完成值更新最新记录, -避免旧 UI 快照覆盖同步元数据,也不在排队结束时反转最新值。写入失败不 -发布已消费标记,保留重试机会;未处理的考试在补账失败期间不显示逾期。 - -## 时钟与生命周期 - -生产环境由 AppController 激活前台完成时钟,AgendaController 只持有一个 -定时器,指向最近未处理考试的结束时间。暂停/后台取消,恢复前台补账; -关闭 App 后错过的结束时间在下次加载补处理,无需常驻后台任务。失败时 -前台最短 30 秒重试,不能对过去的结束时间建立零延时写入循环。 - -测试可注入时钟。通用 TestAppController 默认关闭后台计时,生产时钟测试 -显式开启并在测试正文结束前销毁,不能依赖晚于 Flutter 校验的 teardown。 - -## 同步、编辑与边界 - -同 ID 学校考试刷新时间、地点等元数据时,保留本地 done、handled 和版本; -单纯完成不把整条记录标为 local,避免阻断之后的远端内容更新。编辑器保留 -这些状态字段。DDL 来源原有的远端提交状态策略不改变。 - -当前学校考试 ID 包含开始时间,远端改开始时间会生成新 ID。在没有可靠 -稳定业务 ID 时不按标题猜配,多场考试不能误合并。因此当前只保证可匹配 -的存续同 ID 记录保留选择;不同 ID 是新记录,物理删除/远端消失后重现也 -没有额外的完成 ledger。扩展跨 ID 保留必须先取得可靠身份和迁移策略。 - -列表、详情、课表角标及提醒使用统一完成/逾期状态;学业考试页“过去考试” -和冲突判断仍使用真实时间是否结束,不能因提前手动完成而隐藏未来冲突。 -非 DDL 删除仍采用原来的物理删除+即时撤销,不扩展 DDL 专用回收站范围。 - -Android 日程小组件只读 agenda.json:未完成逾期日程和已撤销考试不因 -结束时间再次被隐藏;未处理到期考试和旧历史日程可只读派生原兼容状态, -仅 Dart 持久化迁移,不让两个运行时同时重写 JSON。学校课程下课过滤不变, -下一次刷新只选未来边界,避免过去时间造成刷新循环。 - -## 回归 - -覆盖模型/JSON、旧数据迁移一次、考试自动一次及撤销重启、人工/自动排队、 -同 ID 同步、失败重试与退避、生命周期、编辑保留、详情/列表完成撤销和 -非 DDL 删除恢复。无本地 Flutter SDK 时,不将静态审查和推送表述为测试通过。 diff --git a/docs/architecture.md b/docs/architecture.md index 85201c8..fd49443 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,7 +80,7 @@ USTC 风控采集失败时遵循学校登录页的协议:仅在风控请求出 ## 日程视图 -日程页提供列表与横向多泳道时间轴,共用类型筛选,读取同一个 `AgendaController`,不混入课表课程。列表保留未完成/已完成分页;时间轴将两种状态同表排布,不按完成状态分配泳道,已完成条目沿用弱化样式。普通日程结束未完成会逾期,考试结束自动完成一次,撤销后保留未完成选择;时间结束与完成分开计算,详见 [日程完成状态](agenda-completion.md)。视图模式通过 `SharedPreferencesAsync` 的 `agendaViewMode` 保存,缺省为瀑布图(时间轴),已有有效偏好仍按用户选择恢复;缩放和浏览位置不写入日程文件。切换视图保留本次浏览位置,离开日程页后再进入会将时间轴定位到今日 12:00 居中。 +日程页提供列表与横向多泳道时间轴,共用类型筛选,读取同一个 `AgendaController`,不混入课表课程。列表保留未完成/已完成分页;时间轴将两种状态同表排布,不按完成状态分配泳道,已完成或已结束条目仍沿用弱化样式。视图模式通过 `SharedPreferencesAsync` 的 `agendaViewMode` 保存,缺省为瀑布图(时间轴),已有有效偏好仍按用户选择恢复;缩放和浏览位置不写入日程文件。切换视图保留本次浏览位置,离开日程页后再进入会将时间轴定位到今日 12:00 居中。 `agenda/timeline_layout.dart` 将完整泳道预留与缩放绘制投影分开。普通 DDL 预留 `[有效截止-D天,真实截止]`,仅开始项目向右预留 D 天,普通日程和考试预留 `[真实开始-D天,真实结束]`;数据和基础布局参数不变时,缩放和平移不会重新分行。D 是设置中的全局排版宽度,默认 4、范围 1~30 天,覆盖旧的单条 `tailDays`(该字段仅保留存储兼容)。DDL 主体和日程左侧信息区按最小缩放 D−0.25 天对应的屏幕像素定宽,默认约 3.75 基线天,不采用 144px 独立正文或随缩放增长的日历天数拖尾。`paintStart/paintEnd` 表示当前实际绘制边界,真实时段和伪截止延伸段随缩放变化。纵向范围取全部筛选条目的最高泳道,不依赖横向可见条目。 @@ -90,7 +90,7 @@ DDL 主体右端为有效 `dueAt`,从左侧渐隐;仅有开放时间的项 时间轴以视口中心时间和连续缩放比例计算坐标,不创建覆盖历史与未来的巨大滚动画布。默认一天一格,最多一小时一格;基准格宽为 48~84 逻辑像素。平移没有预设日期边界,只绘制可见区域附近的网格和条目。顶部工具栏只保留单行并排的「今天」按钮与自适应宽度的缩放滑条,不显示当前日期和常驻的「时间/格」文本,缩放比例仅保留无障碍语义描述。表头基准高度为 24 逻辑像素:不显示小时时,第一行显示日期、第二行居中显示星期;显示小时时,第一行将「日期 星期」作为一个整体居中,第二行显示小时。标签宽度测量包含完整星期与中间空格,固定在对应时间格的中上方,以完整文字绘制并在视口边缘自然裁切,不贴靠屏幕边缘或变成省略号。首个日程条距表头分隔线 12 逻辑像素,与日程条之间的轨道间距一致。工具栏不再包含问号帮助图标及其提示。当前时间线单独在前景绘制,覆盖日程条但不拦截交互,也不绘制圆点。条目单击复用日程详情弹层,编辑、删除、同步后由功能控制器通知刷新。 -时间轴使用独立的语义色,不复用楼栋调色板。考试、大物实验 DDL、乐学 DDL、新延河课堂 DDL 和 MOOC DDL 分别使用黄、蓝、橙、紫和薄荷绿,且每种颜色直接定义浅色与深色的 `fill`、`text`、`accent`。背景使用 fill、文字使用 text、DDL 右侧条与其他日程边框使用 accent。未完成且逾期的 DDL 沿用普通 DDL 的左淡出卡片样式,将 fill 替换为 errorContainer、文字替换为 onErrorContainer、强调色替换为 error;背景右侧再以 20% onErrorContainer 绘制大号警告图标虚影,来源色和错误色不进行长距离混色。末尾直接显示截止时间与“已逾期”。完成该条目后移除红色状态与虚影,并恢复来源色的淡化样式。所有已完成项目的背景、文字、图标及标线统一在原透明度上乘 0.5;左信息区条纹、伪截止延伸和真实标线各自的半透明系数再与完成系数相乘。日程自定义颜色优先,仍保留其原 alpha,不以固定 0.5 覆盖。列表和课表不使用这套映射。 +时间轴使用独立的语义色,不复用楼栋调色板。考试、大物实验 DDL、乐学 DDL、新延河课堂 DDL 和 MOOC DDL 分别使用黄、蓝、橙、紫和薄荷绿,且每种颜色直接定义浅色与深色的 `fill`、`text`、`accent`。背景使用 fill、文字使用 text、DDL 右侧条与其他日程边框使用 accent。未完成且逾期的 DDL 沿用普通 DDL 的左淡出卡片样式,将 fill 替换为 errorContainer、文字替换为 onErrorContainer、强调色替换为 error;背景右侧再以 20% onErrorContainer 绘制大号警告图标虚影,来源色和错误色不进行长距离混色。末尾直接显示截止时间与“已逾期”。完成该条目后移除红色状态与虚影,并恢复来源色的淡化样式。所有已完成或已结束项目的背景、文字、图标及标线统一在原透明度上乘 0.5;左信息区条纹、伪截止延伸和真实标线各自的半透明系数再与完成系数相乘。日程自定义颜色优先,仍保留其原 alpha,不以固定 0.5 覆盖。列表和课表不使用这套映射。 主页课表网格中的日程块不复用时间轴的语义来源色:普通活动使用 `primaryContainer` 背景、`onPrimaryContainer` 文字和 1px `primary` 边框,考试使用 `errorContainer`/`onErrorContainer`/`error`。浅色模式下容器色与页面底色天然接近,可见性由 tone 固定的边框和文字承担,因此开启全局系统调色板后仍能辨认;不使用 `outlineVariant` 等低对比细分隔色。 @@ -122,7 +122,7 @@ DDL 主体右端为有效 `dueAt`,从左侧渐隐;仅有开放时间的项 ## 手势与布局 - `SwipePager` 统一鼠标与触摸分页,拖过半页即报告新页,程序切页不报告中间页。 -- `SwipeActionTile` 以明确的横向意图接管单条操作,不增加按住等待。短滑露出按钮,长滑达到阈值后松手执行;左滑删除,右滑完成或恢复日程、考试或 DDL。快速收回和反向拖回都可以取消。`TapRegion` 处理外部触摸收起,最近的纵向 `ScrollPosition` 处理滚动收起,不额外维护全局打开条目。滑出、按钮停靠与回弹共用动画,保存失败回到原位。行的增删尺寸动画由 `AnimatedSliverChildren` 统一处理。 +- `SwipeActionTile` 以明确的横向意图接管单条操作,不增加按住等待。短滑露出按钮,长滑达到阈值后松手执行;左滑删除,右滑完成或恢复 DDL。快速收回和反向拖回都可以取消。`TapRegion` 处理外部触摸收起,最近的纵向 `ScrollPosition` 处理滚动收起,不额外维护全局打开条目。滑出、按钮停靠与回弹共用动画,保存失败回到原位。行的增删尺寸动画由 `AnimatedSliverChildren` 统一处理。 - `PullToRevealList` 管理下拉手势和展开状态,`RevealScrollController` 在布局阶段同步内容高度与滚动位置。用户重新拖动时解除滚动保持,直接接管滚动。 - `AppMenuAnchor` 使用原生 `RawMenuAnchor` 管理焦点与菜单生命周期,统一面板的开合动效。菜单高度取触发器上下的实际可用空间。 - `SheetLayout`、`EditorSheet` 和主题共同约束弹层尺寸、固定操作区与可滚动内容。地图的缩放、绘制和命中测试由 `WencuiFloorMap` 管理。 diff --git a/docs/deadline-cutoff.md b/docs/deadline-cutoff.md index 6065ce7..7d7ea1e 100644 --- a/docs/deadline-cutoff.md +++ b/docs/deadline-cutoff.md @@ -51,8 +51,7 @@ JSON 设置保存;关闭仍保留所选时间。时间选择页常驻原生 Ma 结束两条 3px 实线框住,其间是 45° 双色宽斜纹(实填充色与高亮色两带 全不透明交替,自开始标线起首带为实色),按实际时距缩放;左侧同样是 45° 斜纹(两带交替使用实填充色与其 0.5 半透明)及向内绘制的 1.5px -高亮色(accent)边框。完成时在原颜色透明度上再乘 0.5,不能把半透明 -部分覆盖成固定透明度。两条时间线重合时结束线最后绘制。所有区域保持 +高亮色(accent)边框。两条时间线重合时结束线最后绘制。所有区域保持 视口裁剪,日期与真实时距不因左侧信息区预留而改变。设置中的“日程与 考试信息栏”开关(默认开启)可隐藏普通日程和考试的信息栏文字、斜纹与 边框,同时取消其左侧排版预留(占位改为 `[真实开始, 真实结束]`); @@ -60,8 +59,6 @@ JSON 设置保存;关闭仍保留所选时间。时间选择页常驻原生 Ma 结束标线、时段斜纹和点击区域不受影响,DDL 与仅开始时间项目不受该 开关影响。 -完成、撤销、考试自动一次和旧数据迁移规则见 [日程完成状态](agenda-completion.md)。 - ## 与主线滚动/性能实现整合 泳道先按上述完整区间分配一次,缩放只更新绘制投影,不重新分配泳道。 diff --git a/lib/academics/ui/exams_screen.dart b/lib/academics/ui/exams_screen.dart index 25f7bd4..416fb1f 100644 --- a/lib/academics/ui/exams_screen.dart +++ b/lib/academics/ui/exams_screen.dart @@ -52,7 +52,7 @@ class _ExamsScreenState extends State { .toList(); final exams = selected - .where((entry) => _showPast || !entry.hasEnded(now)) + .where((entry) => _showPast || !entry.isFinished(now)) .toList() ..sort((a, b) => a.startsAt.compareTo(b.startsAt)); final conflicts = findExamConflicts( @@ -156,7 +156,7 @@ class _ExamsScreenState extends State { term: term, ), ), - if (selected.any((exam) => exam.hasEnded(now))) + if (selected.any((exam) => exam.isFinished(now))) TextButton( onPressed: () => setState(() => _showPast = !_showPast), child: Text( @@ -243,7 +243,7 @@ class _ExamConflictsScreen extends StatelessWidget { (entry) => entry.kind == AgendaKind.exam && (term == null || entry.term == term) && - (showPast || !entry.hasEnded(now)), + (showPast || !entry.isFinished(now)), ), controller.timetable.schedules, includeClasses: includeClasses, diff --git a/lib/agenda/controller.dart b/lib/agenda/controller.dart index 65e0cdb..c2a2842 100644 --- a/lib/agenda/controller.dart +++ b/lib/agenda/controller.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/foundation.dart'; import '../core/async/cancelable_request.dart'; @@ -17,16 +15,11 @@ part 'merge.dart'; class AgendaController extends ChangeNotifier { AgendaController( this._store, { - DateTime Function()? now, void Function(String, Object, StackTrace)? reportError, - }) : _now = now ?? campusNow, - _reportError = reportError ?? AppDiagnostics.instance.report; + }) : _reportError = reportError ?? AppDiagnostics.instance.report; final AgendaStore _store; - final DateTime Function() _now; final void Function(String, Object, StackTrace) _reportError; - Timer? _completionTimer; - bool _completionClockActive = false; List _entries = const []; final _operations = SerialQueue(); bool _ready = false; @@ -65,136 +58,18 @@ class AgendaController extends ChangeNotifier { _notify(); try { _entries = List.unmodifiable(await _store.readAndMigrate()); - final completion = _completionUpdates(_entries, _now()); - if (completion.changed) { - try { - await _store.write(completion.entries); - _entries = List.unmodifiable(completion.entries); - } on Exception catch (failure, stack) { - // Keep the unmigrated records on failure. A later reconciliation can - // retry, rather than claiming the automatic opportunity was consumed. - _reportError('迁移日程完成状态', failure, stack); - } - } _ready = true; } on Exception catch (failure, stack) { _reportError('读取日程', failure, stack); } finally { _busy = false; _notify(); - _scheduleCompletionClock(); - } - }); - - /// Metadata editors opt into retaining the latest completion state when their - /// snapshot was opened before another completion action or clock transition. - /// Ordinary save callers may still intentionally supply completion values. - Future save( - AgendaEntry entry, { - bool preserveCompletion = false, - }) => _write((entries) { - final existing = entries.where((item) => item.id == entry.id).firstOrNull; - final AgendaEntry next; - if (preserveCompletion && existing != null && existing.kind == entry.kind) { - next = entry.copyWith( - done: existing.done, - examAutoCompletionHandled: existing.examAutoCompletionHandled, - completionSchemaVersion: existing.completionSchemaVersion, - ); - } else if (existing != null && - existing.kind == AgendaKind.exam && - entry.kind == AgendaKind.exam && - existing.examAutoCompletionHandled) { - next = entry.copyWith( - examAutoCompletionHandled: true, - // Preserve the existing guard for older callers without the marker. - done: entry.examAutoCompletionHandled ? entry.done : existing.done, - ); - } else { - next = entry; - } - return [...entries.where((item) => item.id != entry.id), next]; - }); - - /// Persist the user's intended value against the latest record, not an old - /// sheet snapshot. Any manual exam choice consumes its automatic opportunity. - Future setDone(String id, bool done) => _operations.run(() async { - if (_disposed || !_ready) return false; - final index = _entries.indexWhere((entry) => entry.id == id && !entry.deleted); - if (index < 0) return false; - final current = _entries[index]; - final handled = current.kind == AgendaKind.exam - ? true - : current.examAutoCompletionHandled; - final version = current.completionSchemaVersion < - AgendaEntry.currentCompletionSchemaVersion - ? AgendaEntry.currentCompletionSchemaVersion - : current.completionSchemaVersion; - if (current.done == done && - current.examAutoCompletionHandled == handled && - current.completionSchemaVersion == version) { - return true; } - final next = [..._entries]; - next[index] = current.copyWith( - done: done, - examAutoCompletionHandled: handled, - completionSchemaVersion: version, - ); - return _commit(next); }); - /// Clock-aware transitions are serialized with edits and syncs. Getters and - /// widget builds remain pure and cannot repeatedly re-complete reopened exams. - Future reconcileExamCompletion({DateTime? now}) => - _operations.run(() async { - if (_disposed || !_ready) return false; - final updates = _completionUpdates(_entries, now ?? _now()); - if (!updates.changed) { - _scheduleCompletionClock(); - return true; - } - return _commit(updates.entries); - }); - - /// The App owns activation (foreground only); this controller owns one timer. - void setCompletionClockActive(bool active) { - if (_disposed) return; - _completionClockActive = active; - _completionTimer?.cancel(); - _completionTimer = null; - if (active) unawaited(reconcileExamCompletion()); - } - - void _scheduleCompletionClock() { - _completionTimer?.cancel(); - _completionTimer = null; - if (_disposed || !_ready || !_completionClockActive) return; - final now = _now(); - final outstanding = _completionUpdates(_entries, now); - DateTime? nextEnd; - for (final entry in _entries) { - if (entry.deleted || - entry.kind != AgendaKind.exam || - entry.examAutoCompletionHandled || - !entry.endsAt!.isAfter(now)) { - continue; - } - if (nextEnd == null || entry.endsAt!.isBefore(nextEnd)) { - nextEnd = entry.endsAt; - } - } - // A failed write leaves its state untouched. Retry while foregrounded, - // without a zero-delay loop that would hammer storage on persistent errors. - final delay = outstanding.changed - ? const Duration(seconds: 30) - : nextEnd?.difference(now); - if (delay == null) return; - _completionTimer = Timer(delay, () { - _completionTimer = null; - unawaited(reconcileExamCompletion()); - }); - } + Future save(AgendaEntry entry) => _write( + (entries) => [...entries.where((item) => item.id != entry.id), entry], + ); Future restore(AgendaEntry entry) => restoreEntries([entry]); @@ -240,29 +115,17 @@ class AgendaController extends ChangeNotifier { List exams, { void Function()? checkActive, }) => _write((entries) { - final savedById = { - for (final entry in entries) - if (entry.kind == AgendaKind.exam && entry.term == term) entry.id: entry, - }; final edited = { - for (final entry in savedById.values) - if (entry.local) entry.id, + for (final entry in entries) + if (entry.kind == AgendaKind.exam && entry.term == term && entry.local) + entry.id, }; return [ ...entries.where( (entry) => entry.kind != AgendaKind.exam || entry.term != term || entry.local, ), - for (final entry in exams) - if (!edited.contains(entry.id)) - if (savedById[entry.id] case final saved?) - entry.copyWith( - done: saved.done, - examAutoCompletionHandled: saved.examAutoCompletionHandled, - completionSchemaVersion: saved.completionSchemaVersion, - ) - else - entry, + ...exams.where((entry) => !edited.contains(entry.id)), ]; }, checkActive: checkActive); @@ -372,9 +235,8 @@ class AgendaController extends ChangeNotifier { _notify(); try { checkActive?.call(); - final prepared = _completionUpdates(next, _now()).entries; - await _store.write(prepared); - _entries = List.unmodifiable(prepared); + await _store.write(next); + _entries = List.unmodifiable(next); return true; } on RequestCancelled { rethrow; @@ -384,7 +246,6 @@ class AgendaController extends ChangeNotifier { } finally { _busy = false; _notify(); - _scheduleCompletionClock(); } } @@ -401,50 +262,6 @@ class AgendaController extends ChangeNotifier { @override void dispose() { _disposed = true; - _completionTimer?.cancel(); - _completionTimer = null; super.dispose(); } } - -({List entries, bool changed}) _completionUpdates( - List entries, - DateTime now, -) { - var changed = false; - final next = []; - for (final entry in entries) { - var done = entry.done; - var handled = entry.examAutoCompletionHandled; - var version = entry.completionSchemaVersion; - if (version < AgendaEntry.currentCompletionSchemaVersion) { - // Old events were presented as finished solely because time elapsed. - // Preserve that history exactly once, never again after an explicit undo. - if (entry.kind == AgendaKind.event && !entry.deleted && entry.hasEnded(now)) { - done = true; - } - version = AgendaEntry.currentCompletionSchemaVersion; - } - if (entry.kind == AgendaKind.exam && done) handled = true; - if (entry.kind == AgendaKind.exam && - !entry.deleted && - !handled && - entry.hasEnded(now)) { - done = true; - handled = true; - } - if (done != entry.done || - handled != entry.examAutoCompletionHandled || - version != entry.completionSchemaVersion) { - changed = true; - next.add(entry.copyWith( - done: done, - examAutoCompletionHandled: handled, - completionSchemaVersion: version, - )); - } else { - next.add(entry); - } - } - return (entries: next, changed: changed); -} diff --git a/lib/agenda/model.dart b/lib/agenda/model.dart index c18a1f0..bc6dc1c 100644 --- a/lib/agenda/model.dart +++ b/lib/agenda/model.dart @@ -22,8 +22,6 @@ class AgendaEntry { this.location = '', this.notes = '', this.done = false, - this.examAutoCompletionHandled = false, - this.completionSchemaVersion = currentCompletionSchemaVersion, this.deleted = false, this.term, this.local = true, @@ -77,7 +75,6 @@ class AgendaEntry { } } - static const currentCompletionSchemaVersion = 1; static const defaultTailDays = 4; static const maxTailDays = 30; @@ -90,14 +87,6 @@ class AgendaEntry { final String location; final String notes; final bool done; - - /// True after an exam's one automatic opportunity or a manual completion - /// choice. Undoing completion never resets this durable marker. - final bool examAutoCompletionHandled; - - /// Zero means an old record still needs its one-time, clock-aware migration. - /// Parsing JSON itself deliberately has no dependence on the current time. - final int completionSchemaVersion; final bool deleted; final String? term; final bool local; @@ -149,21 +138,10 @@ class AgendaEntry { ? this : copyWith(deadlineCutoffWindow: window); - bool overdue(DateTime now) { - if (done || (isDeadline && startOnly)) return false; - // An unhandled exam is awaiting its one automatic completion, not overdue. - // Only an explicitly reopened exam may become overdue after its real end. - if (kind == AgendaKind.exam && !examAutoCompletionHandled) return false; - return isDeadline ? dueAt.isBefore(now) : hasEnded(now); - } - - /// Time passing and completion are separate. In particular, reopening an - /// ended exam must not make it completed again during a widget rebuild. - bool hasEnded(DateTime now) => isDeadline - ? !startOnly && !dueAt.isAfter(now) - : !endsAt!.isAfter(now); + bool overdue(DateTime now) => + isDeadline && !startOnly && !done && dueAt.isBefore(now); - bool isFinished(DateTime now) => done; + bool isFinished(DateTime now) => isDeadline ? done : !endsAt!.isAfter(now); bool occursOn(DateTime date) { final day = calendarDate(date); @@ -174,8 +152,6 @@ class AgendaEntry { AgendaEntry copyWith({ bool? done, - bool? examAutoCompletionHandled, - int? completionSchemaVersion, bool? deleted, DeadlineCutoffWindow? deadlineCutoffWindow, }) => AgendaEntry( @@ -188,10 +164,6 @@ class AgendaEntry { location: location, notes: notes, done: done ?? this.done, - examAutoCompletionHandled: - examAutoCompletionHandled ?? this.examAutoCompletionHandled, - completionSchemaVersion: - completionSchemaVersion ?? this.completionSchemaVersion, deleted: deleted ?? this.deleted, term: term, local: local, @@ -215,8 +187,6 @@ class AgendaEntry { 'location': location, 'notes': notes, 'done': done, - 'examAutoCompletionHandled': examAutoCompletionHandled, - 'completionSchemaVersion': completionSchemaVersion, 'deleted': deleted, 'term': term, 'local': local, @@ -271,10 +241,6 @@ class AgendaEntry { location: jsonStringField(json, 'location'), notes: jsonStringField(json, 'notes'), done: jsonBoolField(json, 'done'), - examAutoCompletionHandled: - jsonBoolField(json, 'examAutoCompletionHandled', fallback: false), - completionSchemaVersion: - jsonIntFieldOrNull(json, 'completionSchemaVersion') ?? 0, deleted: jsonBoolField(json, 'deleted', fallback: false), term: jsonStringFieldOrNull(json, 'term'), local: jsonBoolField(json, 'local'), diff --git a/lib/agenda/ui/agenda_detail.dart b/lib/agenda/ui/agenda_detail.dart index 2b035e8..937645c 100644 --- a/lib/agenda/ui/agenda_detail.dart +++ b/lib/agenda/ui/agenda_detail.dart @@ -37,6 +37,8 @@ Future showAgendaEntry( ? l10n.completed : overdue ? l10n.overdue + : current.isFinished(now) + ? l10n.ended : null; final notes = localizedAgendaNotes(l10n, current); final statusColor = overdue ? scheme.error : scheme.onSurfaceVariant; @@ -189,39 +191,52 @@ Future showAgendaEntry( ], ), ), - actions: SheetActionBar.split( - leading: TextButton.icon( - onPressed: controller.busy ? null : edit, - icon: const Icon(Icons.edit_outlined, size: 18), - label: Text(l10n.edit), - ), - primary: FilledButton.icon( - style: current.done - ? FilledButton.styleFrom( - backgroundColor: scheme.secondaryContainer, - foregroundColor: scheme.onSecondaryContainer, - ) - : null, - onPressed: controller.busy - ? null - : () async { - final saved = await toggleAgendaDone( - controller, - current, - ); - if (!saved && context.mounted) { - showAppNotice(context, context.l10n.operationSaveFailed); - } - }, - icon: Icon( - current.done ? Icons.undo_rounded : Icons.check_rounded, - size: 20, - ), - label: Text( - current.done ? l10n.agendaMarkUndone : l10n.agendaMarkDone, - ), - ), - ), + actions: current.isDeadline + ? SheetActionBar.split( + leading: TextButton.icon( + onPressed: controller.busy ? null : edit, + icon: const Icon(Icons.edit_outlined, size: 18), + label: Text(l10n.edit), + ), + primary: FilledButton.icon( + style: current.done + ? FilledButton.styleFrom( + backgroundColor: scheme.secondaryContainer, + foregroundColor: scheme.onSecondaryContainer, + ) + : null, + onPressed: controller.busy + ? null + : () async { + final saved = await toggleAgendaDone( + controller, + current, + ); + if (!saved && context.mounted) { + showAppNotice( + context, + context.l10n.operationSaveFailed, + ); + } + }, + icon: Icon( + current.done ? Icons.undo_rounded : Icons.check_rounded, + size: 20, + ), + label: Text( + current.done + ? l10n.agendaMarkUndone + : l10n.agendaMarkDone, + ), + ), + ) + : SheetActionBar.primary( + primary: FilledButton.icon( + onPressed: controller.busy ? null : edit, + icon: const Icon(Icons.edit_outlined, size: 18), + label: Text(l10n.edit), + ), + ), ); }, ), diff --git a/lib/agenda/ui/agenda_editor.dart b/lib/agenda/ui/agenda_editor.dart index 5d402d7..15f3733 100644 --- a/lib/agenda/ui/agenda_editor.dart +++ b/lib/agenda/ui/agenda_editor.dart @@ -178,10 +178,6 @@ class _AgendaEditorState extends State<_AgendaEditor> { location: _location.text.trim(), notes: _notes.text.trim(), done: widget.entry?.done ?? false, - examAutoCompletionHandled: - widget.entry?.examAutoCompletionHandled ?? false, - completionSchemaVersion: widget.entry?.completionSchemaVersion ?? - AgendaEntry.currentCompletionSchemaVersion, term: widget.entry?.term ?? widget.term, source: widget.entry?.source, course: widget.entry?.course ?? '', @@ -196,7 +192,6 @@ class _AgendaEditorState extends State<_AgendaEditor> { startOnly: _kind == AgendaKind.deadline && (widget.entry?.startOnly ?? false), ), - preserveCompletion: true, ); if (!mounted) return; if (success) { diff --git a/lib/agenda/ui/agenda_list_tile.dart b/lib/agenda/ui/agenda_list_tile.dart index 00ffb51..b408516 100644 --- a/lib/agenda/ui/agenda_list_tile.dart +++ b/lib/agenda/ui/agenda_list_tile.dart @@ -9,6 +9,7 @@ import '../controller.dart'; import '../model.dart'; import 'agenda_detail.dart'; import 'agenda_operations.dart'; +import 'calendar_list.dart'; import 'completion_text.dart'; import 'timeline_colors.dart'; @@ -81,16 +82,20 @@ class AgendaListTile extends StatelessWidget { onInvoke: () => deleteAgendaEntry(context, controller, entry, confirm: false), ), - rightAction: TileSwipeAction( - label: entry.done ? l10n.agendaSwipeRestore : l10n.agendaSwipeDone, - releaseLabel: entry.done - ? l10n.agendaSwipeRestoreRelease - : l10n.agendaSwipeDoneRelease, - icon: entry.done ? Icons.undo_rounded : Icons.check_rounded, - background: scheme.primaryContainer, - foreground: scheme.onPrimaryContainer, - onInvoke: () => _complete(context), - ), + rightAction: entry.isDeadline + ? TileSwipeAction( + label: entry.done + ? l10n.agendaSwipeRestore + : l10n.agendaSwipeDone, + releaseLabel: entry.done + ? l10n.agendaSwipeRestoreRelease + : l10n.agendaSwipeDoneRelease, + icon: entry.done ? Icons.undo_rounded : Icons.check_rounded, + background: scheme.primaryContainer, + foreground: scheme.onPrimaryContainer, + onInvoke: () => _complete(context), + ) + : null, child: Material( type: MaterialType.transparency, child: InkWell( @@ -105,18 +110,24 @@ class AgendaListTile extends StatelessWidget { children: [ SizedBox.square( dimension: 44, - child: Tooltip( - message: entry.done - ? l10n.agendaMarkUndone - : l10n.agendaMarkDone, - child: Checkbox( - value: entry.done, - shape: const CircleBorder(), - onChanged: controller.busy - ? null - : (_) => _complete(context, haptic: true), - ), - ), + child: entry.isDeadline + ? Tooltip( + message: entry.done + ? l10n.agendaMarkUndone + : l10n.agendaMarkDone, + child: Checkbox( + value: entry.done, + shape: const CircleBorder(), + onChanged: controller.busy + ? null + : (_) => _complete(context, haptic: true), + ), + ) + : Icon( + agendaIcon(entry.kind), + size: 22, + color: scheme.onSurfaceVariant, + ), ), const SizedBox(width: 4), if (entryColors != null) ...[ diff --git a/lib/agenda/ui/agenda_operations.dart b/lib/agenda/ui/agenda_operations.dart index 6fff5d3..91283a5 100644 --- a/lib/agenda/ui/agenda_operations.dart +++ b/lib/agenda/ui/agenda_operations.dart @@ -13,7 +13,7 @@ Future toggleAgendaDone( AgendaEntry entry, { bool haptic = true, }) async { - final saved = await controller.setDone(entry.id, !entry.done); + final saved = await controller.save(entry.copyWith(done: !entry.done)); if (saved && haptic) { HapticFeedback.selectionClick(); } diff --git a/lib/agenda/ui/agenda_reminder.dart b/lib/agenda/ui/agenda_reminder.dart index c500c3b..696fbdb 100644 --- a/lib/agenda/ui/agenda_reminder.dart +++ b/lib/agenda/ui/agenda_reminder.dart @@ -116,7 +116,7 @@ class _AgendaReminderState extends State final caption = entry == null ? '' : entry.overdue(widget.now) - ? l10n.agendaReminderOverdue(localizedAgendaOverdueClock(l10n, entry)) + ? l10n.agendaReminderOverdue(localizedAgendaClock(l10n, entry)) : entry.isDeadline || entry.allDay ? localizedAgendaTime(l10n, entry) : entry.startsAt.isAfter(widget.now) diff --git a/lib/agenda/ui/event_timeline_entry.dart b/lib/agenda/ui/event_timeline_entry.dart index 6f2bd41..d6b343d 100644 --- a/lib/agenda/ui/event_timeline_entry.dart +++ b/lib/agenda/ui/event_timeline_entry.dart @@ -164,7 +164,7 @@ class _EventTimelineEntryState extends State { else if (overdue) l10n.agendaTimelineStatusOverdue, ].join('\n'); final caption = overdue - ? l10n.agendaReminderOverdue(localizedAgendaOverdueClock(l10n, entry)) + ? l10n.agendaReminderOverdue(clockText(entry.endsAt!)) : _eventCaption(l10n, entry); final semantics = l10n.agendaTimelineSemanticsEntry( entry.kind.localizedLabel(l10n), diff --git a/lib/app/controller.dart b/lib/app/controller.dart index 99ff4fe..719cc63 100644 --- a/lib/app/controller.dart +++ b/lib/app/controller.dart @@ -1,4 +1,4 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../academics/controller.dart'; @@ -18,7 +18,7 @@ import 'settings_controller.dart'; /// Composes feature state and owns application startup. /// Feature changes are observed by their views, not relayed through this object. -class AppController extends ChangeNotifier with WidgetsBindingObserver { +class AppController extends ChangeNotifier { AppController({ required ScheduleStore store, required SchoolService school, @@ -29,7 +29,6 @@ class AppController extends ChangeNotifier with WidgetsBindingObserver { SystemPaletteSource? systemPaletteSource, Future> Function()? coursePaletteLoader, MoocStorage? moocStorage, - this.manageAgendaCompletionClock = true, }) : settings = AppSettingsController( preferences, systemPaletteSource: systemPaletteSource, @@ -56,18 +55,6 @@ class AppController extends ChangeNotifier with WidgetsBindingObserver { ); settings.deadlineCutoff.addListener(_updateDeadlineCutoff); _updateDeadlineCutoff(); - if (manageAgendaCompletionClock) { - WidgetsBinding.instance.addObserver(this); - } - } - - final bool manageAgendaCompletionClock; - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (manageAgendaCompletionClock && _ready) { - agenda.setCompletionClockActive(state == AppLifecycleState.resumed); - } } void _updateDeadlineCutoff() => @@ -93,21 +80,11 @@ class AppController extends ChangeNotifier with WidgetsBindingObserver { await mooc.load(); await academics.load(school.username); _ready = true; - if (manageAgendaCompletionClock) { - final lifecycle = WidgetsBinding.instance.lifecycleState; - agenda.setCompletionClockActive( - lifecycle == null || lifecycle == AppLifecycleState.resumed, - ); - } notifyListeners(); } @override void dispose() { - if (manageAgendaCompletionClock) { - WidgetsBinding.instance.removeObserver(this); - } - agenda.setCompletionClockActive(false); settings.deadlineCutoff.removeListener(_updateDeadlineCutoff); settings.dispose(); sync.dispose(); diff --git a/lib/l10n/formats.dart b/lib/l10n/formats.dart index d8c3779..a839371 100644 --- a/lib/l10n/formats.dart +++ b/lib/l10n/formats.dart @@ -281,24 +281,19 @@ String localizedCalendarItemTitle(AppLocalizations l10n, CalendarItem item) => String localizedAgendaClock(AppLocalizations l10n, AgendaEntry entry) => entry.effectiveAllDay ? l10n.allDay : clockText(entry.scheduledAt); -String localizedAgendaOverdueClock(AppLocalizations l10n, AgendaEntry entry) => - entry.effectiveAllDay - ? l10n.allDay - : clockText(entry.isDeadline ? entry.dueAt : entry.endsAt!); - String localizedAgendaTimeCaption( AppLocalizations l10n, AgendaEntry entry, DateTime now, ) { - if (entry.done) return l10n.completed; - if (entry.overdue(now)) return l10n.overdue; if (entry.isDeadline) { + if (entry.done) return l10n.completed; // Overdue is impossible without a due date, and calling the opening time // a due date would be wrong. if (entry.startOnly) return l10n.start; - return l10n.due; + return entry.overdue(now) ? l10n.overdue : l10n.due; } + if (entry.isFinished(now)) return l10n.ended; if (entry.allDay) return ''; final end = entry.endsAt!; final endTime = calendarDate(entry.startsAt) == calendarDate(end) diff --git a/test/agenda_completion_test.dart b/test/agenda_completion_test.dart deleted file mode 100644 index 1057b79..0000000 --- a/test/agenda_completion_test.dart +++ /dev/null @@ -1,596 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:bitree/academics/controller.dart'; -import 'package:bitree/agenda/controller.dart'; -import 'package:bitree/agenda/deadline_source.dart'; -import 'package:bitree/agenda/model.dart'; -import 'package:bitree/agenda/ui/agenda_list_tile.dart'; -import 'package:bitree/agenda/ui/agenda_operations.dart'; -import 'package:bitree/app/controller.dart'; -import 'package:bitree/l10n/app_localizations_zh.dart'; -import 'package:bitree/l10n/formats.dart'; -import 'package:bitree/school/service.dart'; -import 'package:bitree/ui/swipe_action_tile.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(2030, 1, 2, 12); - -AgendaEntry _item({ - String id = 'item', - AgendaKind kind = AgendaKind.event, - DateTime? startsAt, - DateTime? endsAt, - bool done = false, - bool handled = false, - bool local = true, - String title = '日程', - String location = '原地点', -}) => AgendaEntry( - id: id, - title: title, - kind: kind, - startsAt: startsAt ?? _now.subtract(const Duration(hours: 2)), - endsAt: kind == AgendaKind.deadline - ? null - : endsAt ?? _now.subtract(const Duration(hours: 1)), - done: done, - examAutoCompletionHandled: handled, - local: local, - term: 'term', - location: location, -); - -AgendaEntry _legacy(AgendaEntry entry) { - final json = entry.toJson() - ..remove('completionSchemaVersion') - ..remove('examAutoCompletionHandled'); - return AgendaEntry.fromJson(json); -} - -class _Store extends MemoryAgendaStore { - _Store(super.entries); - - int writes = 0; - bool fail = false; - Completer? started; - Completer? release; - - @override - Future write(List entries) async { - writes++; - final waiting = release; - if (waiting != null) { - if (started?.isCompleted == false) started!.complete(); - await waiting.future; - } - if (fail) throw Exception('simulated agenda write failure'); - await super.write(entries); - } -} - -final _reportedErrors = []; - -AgendaController _controller(_Store store, {DateTime Function()? now}) => - AgendaController( - store, - now: now, - reportError: (operation, failure, _) { - _reportedErrors.add('$operation: $failure'); - }, - ); - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - setUp(_reportedErrors.clear); - - test('Elapsed time and completion are independent for all kinds', () { - for (final kind in AgendaKind.values) { - final entry = _item(kind: kind, handled: kind == AgendaKind.exam); - expect(entry.done, isFalse); - expect(entry.isFinished(_now), isFalse); - expect(entry.hasEnded(_now), isTrue); - expect(entry.overdue(_now), isTrue); - final complete = entry.copyWith(done: true); - expect(complete.isFinished(_now), isTrue); - expect(complete.overdue(_now), isFalse); - } - final awaitingAutomatic = _item(kind: AgendaKind.exam); - expect(awaitingAutomatic.hasEnded(_now), isTrue); - expect(awaitingAutomatic.overdue(_now), isFalse); - final future = _item(endsAt: _now.add(const Duration(hours: 1))); - expect(future.hasEnded(_now), isFalse); - expect(future.overdue(_now), isFalse); - expect(future.copyWith(done: true).isFinished(_now), isTrue); - expect(future.copyWith(done: true).hasEnded(_now), isFalse); - final open = AgendaEntry( - id: 'open', title: '开放测验', kind: AgendaKind.deadline, - startsAt: _now.subtract(const Duration(days: 30)), startOnly: true, - ); - expect(open.hasEnded(_now), isFalse); - expect(open.overdue(_now), isFalse); - }); - - test('Non-DDL overdue uses the end and never the cutoff window or start', () { - const cutoff = DeadlineCutoffWindow(enabled: true); - for (final kind in [AgendaKind.event, AgendaKind.exam]) { - final entry = _item( - kind: kind, - handled: kind == AgendaKind.exam, - startsAt: _now.subtract(const Duration(hours: 1)), - endsAt: _now.add(const Duration(hours: 1)), - ).withDeadlineCutoff(cutoff); - expect(entry.hasDeadlineCutoff, isFalse); - expect(entry.overdue(_now), isFalse); - expect(entry.overdue(entry.endsAt!), isTrue); - } - final ddl = _item(kind: AgendaKind.deadline); - expect(ddl.overdue(ddl.dueAt), isFalse); - }); - - test('Completion metadata round-trips and missing schema means legacy only', () { - final entry = _item(kind: AgendaKind.exam).copyWith( - done: false, examAutoCompletionHandled: true, - ); - final restored = AgendaEntry.fromJson( - jsonDecode(jsonEncode(entry.toJson())) as Map, - ); - expect(restored.examAutoCompletionHandled, isTrue); - expect(restored.completionSchemaVersion, AgendaEntry.currentCompletionSchemaVersion); - expect(restored.done, isFalse); - expect(restored.copyWith(deleted: true).examAutoCompletionHandled, isTrue); - final legacy = _legacy(entry); - expect(legacy.done, isFalse); - expect(legacy.completionSchemaVersion, 0); - expect(legacy.examAutoCompletionHandled, isFalse); - expect(_item().completionSchemaVersion, AgendaEntry.currentCompletionSchemaVersion); - }); - - test('Old ended events migrate once, while new past events remain overdue', () async { - final store = _Store([ - _legacy(_item(id: 'legacy')), - _item(id: 'new'), - _legacy(_item(id: 'future', endsAt: _now.add(const Duration(hours: 1)))), - ]); - final controller = _controller(store, now: () => _now); - addTearDown(controller.dispose); - await controller.load(); - expect(controller.entries.singleWhere((e) => e.id == 'legacy').done, isTrue); - expect(controller.entries.singleWhere((e) => e.id == 'new').done, isFalse); - expect(controller.entries.singleWhere((e) => e.id == 'future').done, isFalse); - expect(store.writes, 1); - expect(await controller.setDone('legacy', false), isTrue); - expect(controller.entries.singleWhere((e) => e.id == 'legacy').overdue(_now), isTrue); - await controller.reconcileExamCompletion(now: _now.add(const Duration(days: 1))); - expect(controller.entries.every((e) => !e.done), isTrue); - final reopened = _controller(store, now: () => _now.add(const Duration(days: 2))); - addTearDown(reopened.dispose); - await reopened.load(); - expect(reopened.entries.every((e) => !e.done), isTrue); - expect(store.entries.every((e) => e.completionSchemaVersion == 1), isTrue); - }); - - test('Legacy exam completion is retained and ended exams get one opportunity', () async { - final store = _Store([ - _legacy(_item(id: 'past', kind: AgendaKind.exam)), - _legacy(_item( - id: 'manual', kind: AgendaKind.exam, done: true, - endsAt: _now.add(const Duration(hours: 1)), - )), - ]); - final controller = _controller(store, now: () => _now); - addTearDown(controller.dispose); - await controller.load(); - expect(controller.entries.every((e) => e.done && e.examAutoCompletionHandled), isTrue); - expect(store.writes, 1); - await controller.reconcileExamCompletion(); - await controller.reconcileExamCompletion(); - expect(store.writes, 1); - expect(await controller.setDone('past', false), isTrue); - await controller.reconcileExamCompletion(); - final restarted = _controller(store, now: () => _now.add(const Duration(days: 2))); - addTearDown(restarted.dispose); - await restarted.load(); - final past = restarted.entries.singleWhere((e) => e.id == 'past'); - expect(past.done, isFalse); - expect(past.examAutoCompletionHandled, isTrue); - expect(past.overdue(_now), isTrue); - }); - - test('Manual choices before the end consume the future automatic opportunity', () async { - var clock = _now; - final store = _Store([_item( - kind: AgendaKind.exam, - endsAt: _now.add(const Duration(hours: 1)), - )]); - final controller = _controller(store, now: () => clock); - addTearDown(controller.dispose); - await controller.load(); - expect(await controller.setDone('item', true), isTrue); - expect(await controller.setDone('item', false), isTrue); - clock = _now.add(const Duration(hours: 2)); - await controller.reconcileExamCompletion(); - expect(controller.entries.single.done, isFalse); - expect(controller.entries.single.examAutoCompletionHandled, isTrue); - expect(controller.entries.single.overdue(clock), isTrue); - expect(store.writes, 2); - }); - - test('Failed migration or auto-completion does not consume state', () async { - final store = _Store([_legacy(_item(kind: AgendaKind.exam))])..fail = true; - final controller = _controller(store, now: () => _now); - addTearDown(controller.dispose); - await controller.load(); - expect(controller.ready, isTrue); - expect(controller.entries.single.done, isFalse); - expect(controller.entries.single.examAutoCompletionHandled, isFalse); - expect(controller.entries.single.completionSchemaVersion, 0); - expect(store.entries.single.done, isFalse); - expect(controller.entries.single.overdue(_now), isFalse); - expect( - _reportedErrors, - contains(contains('simulated agenda write failure')), - ); - expect(await controller.reconcileExamCompletion(), isFalse); - store.fail = false; - expect(await controller.reconcileExamCompletion(), isTrue); - expect(controller.entries.single.done, isTrue); - expect(controller.entries.single.examAutoCompletionHandled, isTrue); - final writes = store.writes; - await controller.reconcileExamCompletion(); - expect(store.writes, writes); - }); - - test('Manual write failure leaves completion untouched and can be retried', () async { - final store = _Store([_item(kind: AgendaKind.exam)]); - final controller = _controller(store, now: () => _now); - addTearDown(controller.dispose); - await controller.load(); - store.fail = true; - expect(await controller.setDone('item', false), isFalse); - expect(controller.entries.single.done, isTrue); - store.fail = false; - expect(await controller.setDone('item', false), isTrue); - expect(controller.entries.single.done, isFalse); - expect(controller.entries.single.examAutoCompletionHandled, isTrue); - }); - - test('A queued completion intent cannot be reversed by an automatic transition', () async { - var clock = _now; - final store = _Store([_item( - kind: AgendaKind.exam, - endsAt: _now.add(const Duration(hours: 1)), - )]); - final controller = _controller(store, now: () => clock); - addTearDown(controller.dispose); - await controller.load(); - final snapshot = controller.entries.single; - store.started = Completer(); - store.release = Completer(); - clock = _now.add(const Duration(hours: 2)); - final automatic = controller.reconcileExamCompletion(); - await store.started!.future; - final manual = toggleAgendaDone(controller, snapshot, haptic: false); - store.release!.complete(); - expect(await automatic, isTrue); - expect(await manual, isTrue); - expect(controller.entries.single.done, isTrue); - expect(store.writes, 1); - }); - - test('Completion actions resolve current metadata rather than stale snapshots', () async { - final store = _Store([_item(kind: AgendaKind.exam, endsAt: _now.add(const Duration(hours: 1)))]); - final controller = _controller(store, now: () => _now); - addTearDown(controller.dispose); - await controller.load(); - final snapshot = controller.entries.single; - await controller.save(_item( - kind: AgendaKind.exam, endsAt: _now.add(const Duration(hours: 2)), - title: '更新的标题', location: '新地点', - )); - await toggleAgendaDone(controller, snapshot, haptic: false); - expect(controller.entries.single.title, '更新的标题'); - expect(controller.entries.single.location, '新地点'); - expect(controller.entries.single.endsAt, _now.add(const Duration(hours: 2))); - expect(controller.entries.single.done, isTrue); - expect(controller.entries.single.local, isTrue); - }); - - for (final kind in [AgendaKind.event, AgendaKind.exam]) { - test('Metadata edits retain newer completion choices for $kind', () async { - for (final target in [false, true]) { - final store = _Store([_item( - kind: kind, done: !target, handled: true, - )]); - final controller = _controller(store, now: () => _now); - addTearDown(controller.dispose); - await controller.load(); - final snapshot = controller.entries.single; - expect(snapshot.examAutoCompletionHandled, isTrue); - await controller.setDone('item', target); - final edited = _item( - kind: kind, done: snapshot.done, handled: true, - title: '编辑后的标题', location: '编辑后的地点', - ); - expect(await controller.save(edited, preserveCompletion: true), isTrue); - final saved = controller.entries.single; - expect(saved.done, target); - expect(saved.examAutoCompletionHandled, isTrue); - expect(saved.completionSchemaVersion, - AgendaEntry.currentCompletionSchemaVersion); - expect(saved.title, '编辑后的标题'); - expect(saved.location, '编辑后的地点'); - } - }); - - test('Queued metadata edits read completion at execution time for $kind', () async { - final store = _Store([_item(kind: kind, handled: true)]); - final controller = _controller(store, now: () => _now); - addTearDown(controller.dispose); - await controller.load(); - final snapshot = controller.entries.single; - store.started = Completer(); - store.release = Completer(); - final completing = controller.setDone('item', true); - await store.started!.future; - final editing = controller.save(_item( - kind: kind, done: snapshot.done, handled: true, - title: '排队中的编辑', location: '最新编辑地点', - ), preserveCompletion: true); - store.release!.complete(); - expect(await completing, isTrue); - expect(await editing, isTrue); - expect(controller.entries.single.done, isTrue); - expect(controller.entries.single.examAutoCompletionHandled, isTrue); - expect(controller.entries.single.title, '排队中的编辑'); - expect(controller.entries.single.location, '最新编辑地点'); - }); - - test('Default save can still explicitly change completion for $kind', () async { - final store = _Store([_item(kind: kind, handled: true)]); - final controller = _controller(store, now: () => _now); - addTearDown(controller.dispose); - await controller.load(); - expect(await controller.save(controller.entries.single.copyWith(done: true)), isTrue); - expect(controller.entries.single.done, isTrue); - }); - } - - test('Metadata edits cannot restore legacy migration over a newer undo', () async { - final store = _Store([_item(done: true)]); - final controller = _controller(store, now: () => _now); - addTearDown(controller.dispose); - await controller.load(); - final snapshot = controller.entries.single; - await controller.setDone('item', false); - final oldEditor = snapshot.copyWith(completionSchemaVersion: 0); - expect(await controller.save(oldEditor, preserveCompletion: true), isTrue); - expect(controller.entries.single.done, isFalse); - expect(controller.entries.single.completionSchemaVersion, - AgendaEntry.currentCompletionSchemaVersion); - await controller.load(); - expect(controller.entries.single.done, isFalse); - }); - - test('Same-id sync preserves completion but refreshes remote exam content', () async { - var clock = _now; - final store = _Store([_item( - kind: AgendaKind.exam, local: false, - endsAt: _now.add(const Duration(hours: 1)), - )]); - final controller = _controller(store, now: () => clock); - addTearDown(controller.dispose); - await controller.load(); - await controller.setDone('item', true); - await controller.setDone('item', false); - await controller.replaceExams('term', [_item( - kind: AgendaKind.exam, local: false, title: '远端新标题', location: '新考场', - endsAt: _now.add(const Duration(hours: 2)), - )]); - final current = controller.entries.single; - expect(current.done, isFalse); - expect(current.examAutoCompletionHandled, isTrue); - expect(current.local, isFalse); - expect(current.title, '远端新标题'); - expect(current.location, '新考场'); - clock = _now.add(const Duration(hours: 3)); - await controller.reconcileExamCompletion(); - expect(controller.entries.single.done, isFalse); - // Saving an older editor snapshot without the marker cannot reset it. - await controller.save(_item(kind: AgendaKind.exam, local: false)); - expect(controller.entries.single.done, isFalse); - expect(controller.entries.single.examAutoCompletionHandled, isTrue); - await controller.load(); - expect(controller.entries.single.done, isFalse); - }); - - test('Local content protection and distinct exam identities are not conflated', () async { - final store = _Store([_item(kind: AgendaKind.exam, handled: true)]); - final controller = _controller(store, now: () => _now); - addTearDown(controller.dispose); - await controller.load(); - await controller.replaceExams('term', [_item( - kind: AgendaKind.exam, local: false, title: '应被本地内容保护挡住', - )]); - expect(controller.entries.single.title, '日程'); - expect(controller.entries.single.local, isTrue); - final remoteStore = _Store([_item(kind: AgendaKind.exam, local: false, handled: true)]); - final remote = _controller(remoteStore, now: () => _now); - addTearDown(remote.dispose); - await remote.load(); - await remote.replaceExams('term', [_item( - id: 'new-id', kind: AgendaKind.exam, local: false, - endsAt: _now.add(const Duration(hours: 1)), - )]); - expect(remote.entries.single.id, 'new-id'); - expect(remote.entries.single.examAutoCompletionHandled, isFalse); - }); - - test('Immediate deletion undo preserves a reopened exam opportunity marker', () async { - final store = _Store([_item(kind: AgendaKind.exam)]); - final controller = _controller(store, now: () => _now); - addTearDown(controller.dispose); - await controller.load(); - await controller.setDone('item', false); - final reopened = controller.entries.single; - expect(await controller.delete(reopened), isTrue); - expect(controller.entries, isEmpty); - expect(controller.deletedDeadlines, isEmpty); - expect(await controller.restore(reopened), isTrue); - await controller.reconcileExamCompletion(); - expect(controller.entries.single.done, isFalse); - expect(controller.entries.single.examAutoCompletionHandled, isTrue); - expect(controller.entries.single.overdue(_now), isTrue); - }); - - test('DDL synchronization still owns remote submission completion', () async { - final raw = AgendaEntry( - id: 'ddl', title: '平台作业', kind: AgendaKind.deadline, - startsAt: _now, source: DeadlineSource.eclass, local: false, - ); - final store = _Store([raw]); - final controller = _controller(store, now: () => _now); - addTearDown(controller.dispose); - await controller.load(); - await controller.setDone('ddl', true); - expect(controller.entries.single.done, isTrue); - await controller.mergeDeadlines([raw]); - expect(controller.entries.single.done, isFalse); - expect(controller.entries.single.examAutoCompletionHandled, isFalse); - }); - - testWidgets('The foreground clock completes once at the nearest exam end', (tester) async { - var clock = _now; - final end = _now.add(const Duration(minutes: 1)); - final store = _Store([_item(kind: AgendaKind.exam, endsAt: end)]); - final controller = _controller(store, now: () => clock); - try { - await controller.load(); - controller.setCompletionClockActive(true); - await tester.pump(); - expect(controller.entries.single.done, isFalse); - clock = end; - await tester.pump(const Duration(minutes: 1)); - expect(controller.entries.single.done, isTrue); - expect(controller.entries.single.examAutoCompletionHandled, isTrue); - expect(store.writes, 1); - await controller.setDone('item', false); - clock = end.add(const Duration(days: 1)); - await tester.pump(const Duration(days: 1)); - expect(controller.entries.single.done, isFalse); - expect(store.writes, 2); - } finally { - controller.dispose(); - } - }); - - testWidgets('Failed automatic writes retry with backoff, never a busy loop', (tester) async { - var clock = _now; - final end = _now.add(const Duration(minutes: 1)); - final store = _Store([_item(kind: AgendaKind.exam, endsAt: end)]); - final controller = _controller(store, now: () => clock); - try { - await controller.load(); - controller.setCompletionClockActive(true); - await tester.pump(); - store.fail = true; - clock = end; - await tester.pump(const Duration(minutes: 1)); - expect(store.writes, 1); - expect(_reportedErrors, hasLength(1)); - expect(_reportedErrors.single, contains('simulated agenda write failure')); - expect(controller.entries.single.examAutoCompletionHandled, isFalse); - expect(controller.entries.single.overdue(clock), isFalse); - clock = end.add(const Duration(seconds: 29)); - await tester.pump(const Duration(seconds: 29)); - expect(store.writes, 1); - store.fail = false; - clock = end.add(const Duration(seconds: 30)); - await tester.pump(const Duration(seconds: 1)); - expect(store.writes, 2); - expect(controller.entries.single.done, isTrue); - expect(controller.entries.single.examAutoCompletionHandled, isTrue); - } finally { - controller.dispose(); - } - }); - - testWidgets('Production app lifecycle pauses, resumes and disposes its clock', (tester) async { - var clock = _now; - final store = _Store([_item( - kind: AgendaKind.exam, endsAt: _now.add(const Duration(minutes: 1)), - )]); - final agenda = _controller(store, now: () => clock); - final app = AppController( - store: MemoryScheduleStore(), school: SchoolService(), - schoolStorage: MemorySchoolStorage(), preferences: MemoryPreferences(), - agenda: agenda, academics: AcademicController(MemoryAcademicStore()), - moocStorage: MemoryMoocStorage(), coursePaletteLoader: () async => {}, - ); - try { - expect(app.manageAgendaCompletionClock, isTrue); - await app.initialize(); - await tester.pump(); - app.didChangeAppLifecycleState(AppLifecycleState.paused); - clock = _now.add(const Duration(minutes: 2)); - await tester.pump(const Duration(minutes: 2)); - expect(agenda.entries.single.done, isFalse); - app.didChangeAppLifecycleState(AppLifecycleState.resumed); - await tester.pump(); - expect(agenda.entries.single.done, isTrue); - await agenda.setDone('item', false); - app.didChangeAppLifecycleState(AppLifecycleState.paused); - app.didChangeAppLifecycleState(AppLifecycleState.resumed); - await tester.pump(); - expect(agenda.entries.single.done, isFalse); - } finally { - app.dispose(); - } - await tester.pump(const Duration(days: 1)); - expect(store.writes, 2); - expect(tester.takeException(), isNull); - }); - - for (final kind in [AgendaKind.event, AgendaKind.exam]) { - testWidgets('$kind list and detail both expose reversible completion', (tester) async { - final raw = _item(kind: kind, handled: true); - final agenda = _controller(_Store([raw]), now: () => _now); - final app = TestAppController(agenda: agenda); - await app.initialize(); - await tester.pumpWidget(MaterialApp( - theme: bitreeTheme(Brightness.light), - home: Scaffold(body: ListenableBuilder( - listenable: agenda, - builder: (context, _) => AgendaListTile( - controller: agenda, entry: agenda.entries.single, now: _now, - ), - )), - )); - await tester.pumpAndSettle(); - expect(find.byType(Checkbox), findsOneWidget); - final tile = tester.widget(find.byType(SwipeActionTile)); - expect(tile.rightAction, isNotNull); - await tester.tap(find.byType(Checkbox)); - await tester.pumpAndSettle(); - expect(agenda.entries.single.done, isTrue); - final l10n = AppLocalizationsZh(); - expect(localizedAgendaTimeCaption(l10n, agenda.entries.single, _now), l10n.completed); - // CompletionText paints separate normal/strike-through text layers. - // Activate the row rather than ambiguously matching both title widgets. - await tester.tap(find.byType(AgendaListTile)); - await tester.pumpAndSettle(); - expect(find.widgetWithText(FilledButton, l10n.agendaMarkUndone), findsOneWidget); - await tester.tap(find.widgetWithText(FilledButton, l10n.agendaMarkUndone)); - await tester.pumpAndSettle(); - expect(agenda.entries.single.done, isFalse); - expect(agenda.entries.single.overdue(_now), isTrue); - expect(find.widgetWithText(FilledButton, l10n.agendaMarkDone), findsOneWidget); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox()); - }); - } -} diff --git a/test/agenda_detail_test.dart b/test/agenda_detail_test.dart index c40829e..390fd13 100644 --- a/test/agenda_detail_test.dart +++ b/test/agenda_detail_test.dart @@ -180,51 +180,6 @@ void main() { }, ); - for (final kind in [AgendaKind.event, AgendaKind.exam]) { - testWidgets('A past $kind completes and reopens in the same detail sheet', ( - tester, - ) async { - final entry = AgendaEntry( - id: 'past-${kind.name}', - title: '过期待办', - kind: kind, - startsAt: DateTime.utc(2020, 10, 27, 9), - endsAt: DateTime.utc(2020, 10, 27, 11), - done: false, - // This exam already used its automatic completion opportunity; opening - // or editing its sheet must not consume the user's explicit undo. - examAutoCompletionHandled: kind == AgendaKind.exam, - completionSchemaVersion: AgendaEntry.currentCompletionSchemaVersion, - ); - final controller = await open(tester, entry); - final sheet = tester.element(find.byType(SheetLayout)); - expect(find.text('已逾期'), findsOneWidget); - expect(find.widgetWithText(TextButton, '编辑'), findsOneWidget); - expect(find.widgetWithText(FilledButton, '标记完成'), findsOneWidget); - await tester.tap(find.widgetWithText(FilledButton, '标记完成')); - await tester.pumpAndSettle(); - expect(tester.element(find.byType(SheetLayout)), same(sheet)); - expect(controller.agenda.entries.single.done, isTrue); - expect(find.text('已完成'), findsOneWidget); - expect(find.text('已逾期'), findsNothing); - expect( - tester.widget(find.byType(CompletionText)).completed, - isTrue, - ); - await tester.tap(find.widgetWithText(FilledButton, '标为未完成')); - await tester.pumpAndSettle(); - expect(tester.element(find.byType(SheetLayout)), same(sheet)); - final reopened = controller.agenda.entries.single; - expect(reopened.done, isFalse); - expect(reopened.overdue(DateTime.utc(2030)), isTrue); - if (kind == AgendaKind.exam) { - expect(reopened.examAutoCompletionHandled, isTrue); - } - expect(find.text('已逾期'), findsOneWidget); - expect(tester.takeException(), isNull); - }); - } - testWidgets('Delete still asks for confirmation and closes after deletion', ( tester, ) async { @@ -391,13 +346,11 @@ void main() { endsAt: DateTime.utc(2026, 12, 28, 11), location: '文萃楼F704', notes: '座位号:12', - done: true, - examAutoCompletionHandled: true, term: '2026-2027-1', local: false, ); final controller = await open(tester, entry); - await tester.tap(find.widgetWithText(TextButton, '编辑')); + await tester.tap(find.widgetWithText(FilledButton, '编辑')); await tester.pumpAndSettle(); expect(find.text('编辑考试'), findsOneWidget); expect(find.text('拖尾长度(天)'), findsNothing); @@ -418,9 +371,6 @@ void main() { expect(saved.location, entry.location); expect(saved.notes, entry.notes); expect(saved.term, entry.term); - expect(saved.done, isTrue); - expect(saved.examAutoCompletionHandled, isTrue); - expect(saved.completionSchemaVersion, entry.completionSchemaVersion); expect(saved.local, isTrue); expect(find.byType(SheetLayout), findsNothing); expect(tester.takeException(), isNull); @@ -438,8 +388,6 @@ void main() { location: '文萃楼F704', term: '2026-2027-1', local: false, - done: false, - examAutoCompletionHandled: true, details: const ExamAgendaDetails( seat: '12', mode: '闭卷', @@ -447,7 +395,7 @@ void main() { ), ); final controller = await open(tester, entry); - await tester.tap(find.widgetWithText(TextButton, '编辑')); + await tester.tap(find.widgetWithText(FilledButton, '编辑')); await tester.pumpAndSettle(); final notes = find.byWidgetPredicate( (widget) => @@ -462,9 +410,6 @@ void main() { final saved = controller.agenda.entries.single; expect(saved.details, isNull); expect(saved.notes, '座位号:12\n闭卷\n教师:张老师'); - expect(saved.done, isFalse); - expect(saved.examAutoCompletionHandled, isTrue); - expect(saved.completionSchemaVersion, entry.completionSchemaVersion); expect(tester.takeException(), isNull); }, ); @@ -487,8 +432,6 @@ void main() { startsAt: start, endsAt: end, allDay: true, - done: false, - completionSchemaVersion: AgendaEntry.currentCompletionSchemaVersion, ), ); expect( @@ -499,9 +442,8 @@ void main() { findsOneWidget, ); expect(find.text('全天', findRichText: true), findsOneWidget); - expect(find.text('已结束'), findsNothing); - expect(find.text('已逾期'), findsOneWidget); - expect(find.widgetWithText(FilledButton, '标记完成'), findsOneWidget); + expect(find.text('已结束'), findsOneWidget); + expect(find.text('标记完成'), findsNothing); expect(tester.takeException(), isNull); }, ); @@ -549,7 +491,7 @@ void main() { endsAt: DateTime.utc(2020, 10, 27, 20), ); final controller = await open(tester, entry); - await tester.tap(find.widgetWithText(TextButton, '编辑')); + await tester.tap(find.widgetWithText(FilledButton, '编辑')); await tester.pumpAndSettle(); final colorField = find.byKey(const ValueKey('agenda-color-field')); @@ -580,7 +522,7 @@ void main() { endsAt: DateTime.utc(2020, 10, 27, 20), ); final controller = await open(tester, entry); - await tester.tap(find.widgetWithText(TextButton, '编辑')); + await tester.tap(find.widgetWithText(FilledButton, '编辑')); await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('agenda-color-field'))); await tester.pumpAndSettle(); diff --git a/test/agenda_swipe_test.dart b/test/agenda_swipe_test.dart index c19a091..e93ac1b 100644 --- a/test/agenda_swipe_test.dart +++ b/test/agenda_swipe_test.dart @@ -369,75 +369,6 @@ void main() { expect(tester.takeException(), isNull); }); - for (final kind in [AgendaKind.event, AgendaKind.exam]) { - testWidgets('$kind completes and restores by swipe, and still deletes left', ( - tester, - ) async { - final app = await showAgenda(tester, kind: kind); - var row = find.byKey(const ValueKey('task')).hitTestable(); - expect( - find.descendant(of: row, matching: find.byType(Checkbox)), - findsOneWidget, - ); - await swipeRow(tester, row, const Offset(250, 0)); - await tester.pumpAndSettle(); - expect(app.agenda.entries.single.kind, kind); - expect(app.agenda.entries.single.done, isTrue); - if (kind == AgendaKind.exam) { - expect(app.agenda.entries.single.examAutoCompletionHandled, isTrue); - } - await tester.tap(find.text('已完成 1')); - await tester.pumpAndSettle(); - row = find.byKey(const ValueKey('task')).hitTestable(); - expect(row, findsOneWidget); - await swipeRow(tester, row, const Offset(250, 0)); - await tester.pumpAndSettle(); - expect(app.agenda.entries.single.done, isFalse); - if (kind == AgendaKind.exam) { - expect(app.agenda.entries.single.examAutoCompletionHandled, isTrue); - } - await tester.tap(find.text('未完成 1')); - await tester.pumpAndSettle(); - row = find.byKey(const ValueKey('task')).hitTestable(); - expect(row, findsOneWidget); - await swipeRow(tester, row, const Offset(-90, 0)); - await tester.pumpAndSettle(); - await tester.tap(find.text('删除')); - await tester.pumpAndSettle(); - expect(app.agenda.entries, isEmpty); - // The DDL recycle bin remains DDL-only despite sharing completion UI. - expect(app.agenda.deletedDeadlines, isEmpty); - expect(tester.takeException(), isNull); - }); - } - - for (final kind in [AgendaKind.event, AgendaKind.exam]) { - testWidgets('$kind completion checkbox works in both status lists', ( - tester, - ) async { - final app = await showAgenda(tester, kind: kind); - Finder checkbox() => find.descendant( - of: find.byKey(const ValueKey('task')).hitTestable(), - matching: find.byType(Checkbox), - ); - expect(tester.widget(checkbox()).value, isFalse); - await tester.tap(checkbox()); - await tester.pumpAndSettle(); - expect(app.agenda.entries.single.done, isTrue); - await tester.tap(find.text('已完成 1')); - await tester.pumpAndSettle(); - expect(tester.widget(checkbox()).value, isTrue); - await tester.tap(checkbox()); - await tester.pumpAndSettle(); - expect(app.agenda.entries.single.done, isFalse); - expect(find.text('未完成 1'), findsOneWidget); - if (kind == AgendaKind.exam) { - expect(app.agenda.entries.single.examAutoCompletionHandled, isTrue); - } - expect(tester.takeException(), isNull); - }); - } - testWidgets('Swiping away deletion feedback keeps the deadline deleted', ( tester, ) async { diff --git a/test/calendar_block_test.dart b/test/calendar_block_test.dart index dc8615c..d8b3185 100644 --- a/test/calendar_block_test.dart +++ b/test/calendar_block_test.dart @@ -195,19 +195,11 @@ void main() { final context = tester.element(find.byType(CalendarBlockTile)); final timeline = agendaTimelineColors(context, event, startsAt); expect(timeline, (fill: fill, text: text, accent: accent)); - final afterEnd = startsAt.add(const Duration(hours: 2)); - // A new unfinished event becomes overdue, not automatically done. - expect(event.isFinished(afterEnd), isFalse); - expect(event.overdue(afterEnd), isTrue); - expect( - agendaTimelineColors(context, event, afterEnd).fill, - scheme.errorContainer, - ); if (cat != null) { final finished = agendaTimelineColors( context, - event.copyWith(done: true), - afterEnd, + event, + startsAt.add(const Duration(hours: 2)), ); expect(finished.fill, fill.withValues(alpha: fill.a * 0.5)); final deadline = AgendaEntry( diff --git a/test/deadline_cutoff_badge_test.dart b/test/deadline_cutoff_badge_test.dart index 01db659..4413343 100644 --- a/test/deadline_cutoff_badge_test.dart +++ b/test/deadline_cutoff_badge_test.dart @@ -49,7 +49,7 @@ Future _showHeader( addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetPhysicalSize); final app = TestAppController( - agenda: AgendaController(MemoryAgendaStore(entries), now: () => _now), + agenda: AgendaController(MemoryAgendaStore(entries)), ); await app.initialize(); await tester.pumpWidget( diff --git a/test/deadline_cutoff_test.dart b/test/deadline_cutoff_test.dart index 3fe1fad..569f00a 100644 --- a/test/deadline_cutoff_test.dart +++ b/test/deadline_cutoff_test.dart @@ -332,9 +332,7 @@ void main() { expect(shown.scheduledAt, raw.startsAt); expect(shown.hasDeadlineCutoff, isFalse); expect(shown.hasAdjustedDeadline, isFalse); - final later = DateTime.utc(2030); - expect(shown.overdue(later), raw.overdue(later)); - expect(shown.overdue(later), raw.kind == AgendaKind.event); + expect(shown.overdue(DateTime.utc(2030)), isFalse); } }); }); diff --git a/test/exam_completion_filter_test.dart b/test/exam_completion_filter_test.dart deleted file mode 100644 index 7eb7417..0000000 --- a/test/exam_completion_filter_test.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:bitree/academics/ui/exams_screen.dart'; -import 'package:bitree/agenda/model.dart'; -import 'package:bitree/l10n/app_localizations_zh.dart'; -import 'package:bitree/ui/theme.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'support/test_app.dart'; - -AgendaEntry _exam( - String id, - DateTime start, { - bool done = false, - bool handled = false, -}) => AgendaEntry( - id: id, - title: id, - kind: AgendaKind.exam, - startsAt: start, - endsAt: start.add(const Duration(hours: 2)), - done: done, - examAutoCompletionHandled: handled, -); - -Future _show(WidgetTester tester, List entries) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - final app = TestAppController(entries: entries); - await app.initialize(); - await tester.pumpWidget(MaterialApp( - theme: bitreeTheme(Brightness.light), - home: ExamsScreen(controller: app), - )); - await tester.pumpAndSettle(); -} - -void main() { - final l10n = AppLocalizationsZh(); - - testWidgets('Manual completion cannot hide a future exam or its conflicts', ( - tester, - ) async { - await _show(tester, [ - _exam('未来已完成考试', DateTime.utc(2099, 1, 2, 9), - done: true, handled: true), - _exam('未来待考考试', DateTime.utc(2099, 1, 2, 10)), - ]); - expect(find.text('未来已完成考试'), findsOneWidget); - expect(find.text('未来待考考试'), findsOneWidget); - expect(find.text(l10n.showFinishedExams), findsNothing); - final conflicts = find.text(l10n.timeConflictsFound(1)); - await tester.scrollUntilVisible( - conflicts, 160, scrollable: find.byType(Scrollable).first, - ); - await tester.pumpAndSettle(); - await tester.tap(conflicts); - await tester.pumpAndSettle(); - expect(find.text(l10n.noTimeConflict), findsNothing); - expect(find.textContaining('未来已完成考试'), findsWidgets); - expect(find.textContaining('未来待考考试'), findsWidgets); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox()); - }); - - testWidgets('A reopened past exam remains accessible through the past filter', ( - tester, - ) async { - await _show(tester, [ - _exam('已撤销完成的过去考试', DateTime.utc(2020, 1, 2, 9), handled: true), - ]); - expect(find.text('已撤销完成的过去考试'), findsNothing); - final showPast = find.text(l10n.showFinishedExams); - await tester.scrollUntilVisible( - showPast, 160, scrollable: find.byType(Scrollable).first, - ); - await tester.pumpAndSettle(); - await tester.tap(showPast); - await tester.pumpAndSettle(); - expect(find.text('已撤销完成的过去考试'), findsOneWidget); - expect(find.text(l10n.hideFinishedExams), findsOneWidget); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox()); - }); -} diff --git a/test/storage_compatibility_test.dart b/test/storage_compatibility_test.dart index c6df888..86277bd 100644 --- a/test/storage_compatibility_test.dart +++ b/test/storage_compatibility_test.dart @@ -4,7 +4,6 @@ import 'dart:io'; import 'package:bitree/academics/model.dart'; import 'package:bitree/academics/store.dart'; import 'package:bitree/agenda/agenda_color.dart'; -import 'package:bitree/agenda/controller.dart'; import 'package:bitree/agenda/deadline_source.dart'; import 'package:bitree/agenda/model.dart'; import 'package:bitree/agenda/store.dart'; @@ -436,114 +435,6 @@ void main() { }); }); - test('Versioned agenda retains colors, raw dates and reopened completion', () async { - final now = DateTime.utc(2030, 1, 3, 12); - final rawDue = DateTime.utc(2030, 1, 3, 7); - final examStart = DateTime.utc(2030, 1, 3, 9); - final store = _CountingAgendaStore(); - await store.write([ - AgendaEntry( - id: 'colored-ddl', title: '自定义作业', kind: AgendaKind.deadline, - startsAt: rawDue, color: const AgendaColor(0x803366CC), - deadlineCutoffWindow: const DeadlineCutoffWindow(enabled: true), - ), - AgendaEntry( - id: 'reopened-exam', title: '撤销考试', kind: AgendaKind.exam, - startsAt: examStart, endsAt: examStart.add(const Duration(hours: 2)), - examAutoCompletionHandled: true, - ), - ]); - store.writes = 0; - final controller = AgendaController(store, now: () => now); - addTearDown(controller.dispose); - controller.setDeadlineCutoff(const DeadlineCutoffWindow(enabled: true)); - await controller.load(); - expect(store.writes, 0); - final ddl = controller.entries.singleWhere((entry) => entry.id == 'colored-ddl'); - expect(ddl.startsAt, rawDue); - expect(ddl.realDueAt, rawDue); - expect(ddl.dueAt, DateTime.utc(2030, 1, 2, 22)); - expect(ddl.color, const AgendaColor(0x803366CC)); - expect(ddl.copyWith(done: true).color, ddl.color); - final reopened = controller.entries.singleWhere((entry) => entry.id == 'reopened-exam'); - expect(reopened.done, isFalse); - expect(reopened.examAutoCompletionHandled, isTrue); - expect(reopened.overdue(now), isTrue); - await controller.setDone('colored-ddl', true); - final document = jsonDecode( - await File('${directory.path}/agenda.json').readAsString(), - ) as Map; - expect(document['schemaVersion'], currentStorageSchemaVersion); - final rows = (document['entries'] as List).cast>(); - final storedDdl = rows.singleWhere((row) => row['id'] == 'colored-ddl'); - expect(storedDdl['color'], 0x803366CC); - expect(storedDdl['startsAt'], rawDue.toIso8601String()); - expect(storedDdl.containsKey('deadlineCutoffWindow'), isFalse); - expect(storedDdl.containsKey('dueAt'), isFalse); - expect(storedDdl['completionSchemaVersion'], AgendaEntry.currentCompletionSchemaVersion); - final restartStore = _CountingAgendaStore(); - final restarted = AgendaController(restartStore, now: () => now.add(const Duration(days: 1))); - addTearDown(restarted.dispose); - await restarted.load(); - await restarted.reconcileExamCompletion(); - expect(restartStore.writes, 0); - expect(restarted.entries.singleWhere((entry) => entry.id == 'colored-ddl').color, ddl.color); - final exam = restarted.entries.singleWhere((entry) => entry.id == 'reopened-exam'); - expect(exam.startsAt, examStart); - expect(exam.done, isFalse); - expect(exam.examAutoCompletionHandled, isTrue); - }); - - test('Legacy envelope and completion migrations run only once across undo', () async { - final now = DateTime.utc(2030, 1, 3, 12); - final start = DateTime.utc(2030, 1, 3, 9); - Map legacy(AgendaEntry entry) => entry.toJson() - ..remove('completionSchemaVersion') - ..remove('examAutoCompletionHandled'); - final file = File('${directory.path}/agenda.json'); - await file.writeAsString(jsonEncode([ - legacy(AgendaEntry( - id: 'legacy-event', title: '旧日程', kind: AgendaKind.event, - startsAt: start, endsAt: start.add(const Duration(hours: 1)), - color: AgendaColor.coral, - )), - legacy(AgendaEntry( - id: 'legacy-exam', title: '旧考试', kind: AgendaKind.exam, - startsAt: start, endsAt: start.add(const Duration(hours: 2)), - )), - ])); - final store = _CountingAgendaStore(); - final controller = AgendaController(store, now: () => now); - addTearDown(controller.dispose); - await controller.load(); - // File-envelope migration and clock-aware completion migration are - // separate one-time writes, not competing schema versions. - expect(store.writes, 2); - expect(controller.entries.every((entry) => entry.done), isTrue); - expect(controller.entries.singleWhere((entry) => entry.id == 'legacy-event').color, AgendaColor.coral); - await controller.load(); - await controller.reconcileExamCompletion(); - expect(store.writes, 2); - expect(await controller.setDone('legacy-event', false), isTrue); - expect(await controller.setDone('legacy-exam', false), isTrue); - final beforeRestart = await file.readAsString(); - final document = jsonDecode(beforeRestart) as Map; - expect(document['schemaVersion'], currentStorageSchemaVersion); - final rows = (document['entries'] as List).cast>(); - expect(rows.every((row) => row['completionSchemaVersion'] == AgendaEntry.currentCompletionSchemaVersion), isTrue); - expect(rows.singleWhere((row) => row['id'] == 'legacy-exam')['examAutoCompletionHandled'], isTrue); - expect(rows.singleWhere((row) => row['id'] == 'legacy-event')['color'], 'coral'); - final restartedStore = _CountingAgendaStore(); - final restarted = AgendaController(restartedStore, now: () => now.add(const Duration(days: 1))); - addTearDown(restarted.dispose); - await restarted.load(); - await restarted.reconcileExamCompletion(); - expect(restartedStore.writes, 0); - expect(restarted.entries.every((entry) => !entry.done && entry.startsAt == start), isTrue); - expect(restarted.entries.every((entry) => entry.overdue(now)), isTrue); - expect(await file.readAsString(), beforeRestart); - }); - for (final name in ['schedule', 'academics']) { final fixture = name == 'schedule' ? 'test/fixtures/legacy/v0.2.0/schedule.json' @@ -610,13 +501,3 @@ void main() { } }); } - -class _CountingAgendaStore extends AgendaStore { - int writes = 0; - - @override - Future write(List entries) async { - writes++; - await super.write(entries); - } -} diff --git a/test/support/test_app.dart b/test/support/test_app.dart index 8ad13f0..2b35a15 100644 --- a/test/support/test_app.dart +++ b/test/support/test_app.dart @@ -138,7 +138,6 @@ class TestAppController extends AppController { AcademicStore? academicStore, super.systemPaletteSource, }) : super( - manageAgendaCompletionClock: false, coursePaletteLoader: () async => testPalettes(), store: scheduleStore ?? From 15a497ebb06f455f0f0da75de2271a0e1730052e Mon Sep 17 00:00:00 2001 From: dongdigua Date: Thu, 12 Nov 2026 04:31:08 +0000 Subject: [PATCH 21/24] fix(agenda): restore event cards and per-item deadline tails Use global layout width as the fallback for individual DDL tail lengths, restore the editor controls, and preserve overrides during sync. Restore legacy event boxes when information bars are disabled, simplify settings copy, and reuse the existing time picker for deadline cutoffs. (cherry picked from commit 73b6b3be4047bec50fd8a42b7f5e93d2ba216c63) --- docs/architecture.md | 4 +- docs/deadline-cutoff.md | 24 +- lib/agenda/merge.dart | 1 + lib/agenda/model.dart | 17 +- lib/agenda/timeline_layout.dart | 60 ++- lib/agenda/ui/agenda_editor.dart | 8 +- lib/agenda/ui/agenda_editor_sections.dart | 34 ++ lib/agenda/ui/agenda_timeline.dart | 68 ++- lib/app/ui/deadline_cutoff_settings.dart | 394 +++------------ lib/l10n/app_en.arb | 11 +- lib/l10n/app_localizations.dart | 16 +- lib/l10n/app_localizations_en.dart | 22 +- lib/l10n/app_localizations_zh.dart | 19 +- lib/l10n/app_zh.arb | 11 +- test/agenda_adjusted_timeline_test.dart | 154 +++--- test/agenda_detail_test.dart | 167 +++--- test/agenda_layout_days_test.dart | 331 ++++++++---- test/agenda_timeline_projection_test.dart | 168 ++++--- test/deadline_cutoff_settings_test.dart | 588 +++++++++++----------- test/event_info_bar_test.dart | 319 ++++++------ test/storage_compatibility_test.dart | 9 +- 21 files changed, 1250 insertions(+), 1175 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index fd49443..e22fa67 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,11 +82,11 @@ USTC 风控采集失败时遵循学校登录页的协议:仅在风控请求出 日程页提供列表与横向多泳道时间轴,共用类型筛选,读取同一个 `AgendaController`,不混入课表课程。列表保留未完成/已完成分页;时间轴将两种状态同表排布,不按完成状态分配泳道,已完成或已结束条目仍沿用弱化样式。视图模式通过 `SharedPreferencesAsync` 的 `agendaViewMode` 保存,缺省为瀑布图(时间轴),已有有效偏好仍按用户选择恢复;缩放和浏览位置不写入日程文件。切换视图保留本次浏览位置,离开日程页后再进入会将时间轴定位到今日 12:00 居中。 -`agenda/timeline_layout.dart` 将完整泳道预留与缩放绘制投影分开。普通 DDL 预留 `[有效截止-D天,真实截止]`,仅开始项目向右预留 D 天,普通日程和考试预留 `[真实开始-D天,真实结束]`;数据和基础布局参数不变时,缩放和平移不会重新分行。D 是设置中的全局排版宽度,默认 4、范围 1~30 天,覆盖旧的单条 `tailDays`(该字段仅保留存储兼容)。DDL 主体和日程左侧信息区按最小缩放 D−0.25 天对应的屏幕像素定宽,默认约 3.75 基线天,不采用 144px 独立正文或随缩放增长的日历天数拖尾。`paintStart/paintEnd` 表示当前实际绘制边界,真实时段和伪截止延伸段随缩放变化。纵向范围取全部筛选条目的最高泳道,不依赖横向可见条目。 +`agenda/timeline_layout.dart` 将完整泳道预留与缩放绘制投影分开。普通 DDL 预留 `[有效截止-D天,真实截止]`,仅开始项目向右预留 D 天,普通日程和考试预留 `[真实开始-D天,真实结束]`;数据和基础布局参数不变时,缩放和平移不会重新分行。D 优先使用单条 DDL 的 `tailDays`;没有单独设置(`tailDays` 缺失或为 null)时跟随全局默认排版宽度,初始 4、范围 1~30 天。旧记录中已保存的单条长度保留优先级,编辑页“更多选项”提供拖尾滑条和“跟随全局设置”开关。普通日程和考试的信息区继续使用全局默认值。DDL 主体和日程左侧信息区按最小缩放 D−0.25 天对应的屏幕像素定宽,默认约 3.75 基线天,不采用 144px 独立正文或随缩放增长的日历天数拖尾。`paintStart/paintEnd` 表示当前实际绘制边界,真实时段和伪截止延伸段随缩放变化。纵向范围取全部筛选条目的最高泳道,不依赖横向可见条目。 `AgendaTimelineEntry` 只负责条目的外观和交互,不处理坐标或泳道。日程与考试在标题下常驻真实起止时间,跨天时分两行标出日期;全天日程显示日期范围。DDL 保留课程/来源,并另列截止时间。长按或鼠标悬停显示包含开始、结束或截止日期、课程及地点的分行预览;点按沿用详情抽屉。卡片最小高度为 56 逻辑像素,并随字体放大,确保完整时间与来源信息有独立空间。 -DDL 主体右端为有效 `dueAt`,从左侧渐隐;仅有开放时间的项目从左向右淡出。主体屏幕定宽,不因邻卡裁短信息区。每日伪提前窗口支持关闭、跨日、两端包含及起止同点;原始截止不写回覆盖。命中窗口时,有效与真实截止之间绘制随时间缩放的宽斜纹矩形,真实标线使用变淡色并始终最后绘制,包括重叠情形。普通日程和考试真实时段为两条实线之间 45° 双色宽斜纹填充(实填充色与高亮色两带全不透明交替,首带为实色),左信息区使用实填充色与其半透明交替的斜纹及向内半线宽高亮色边框;设置中的“日程与考试信息栏”开关(默认开启)可隐藏日程与考试的左信息栏并同时取消其左侧预留,占位改为 `[真实开始, 真实结束]`,隐藏时绘制面额外保留一个线宽使开始标线可见,DDL、仅开始项目与真实时段绘制不受影响。各区域完成透明度都乘原 alpha,已半透明区域再衰减,详见 [布局与伪截止规则](deadline-cutoff.md)。全天 DDL 的真实截止仍为对应日期次日零点,只有有效截止可派生提前。 +DDL 主体右端为有效 `dueAt`,从左侧渐隐;仅有开放时间的项目从左向右淡出。主体屏幕定宽,不因邻卡裁短信息区。每日伪提前窗口支持关闭、跨日、两端包含及起止同点;原始截止不写回覆盖。命中窗口时,有效与真实截止之间绘制随时间缩放的宽斜纹矩形,真实标线使用变淡色并始终最后绘制,包括重叠情形。普通日程和考试真实时段为两条实线之间 45° 双色宽斜纹填充(实填充色与高亮色两带全不透明交替,首带为实色),左信息区使用实填充色与其半透明交替的斜纹及向内半线宽高亮色边框;设置中的“日程与考试信息栏”开关(默认开启)关闭时改用旧版方框卡片,在真实时段内显示标题与时间,并取消左侧信息区预留,占位改为 `[真实开始, 真实结束]`;DDL、仅开始项目不受影响。各区域完成透明度都乘原 alpha,已半透明区域再衰减,详见 [布局与伪截止规则](deadline-cutoff.md)。全天 DDL 的真实截止仍为对应日期次日零点,只有有效截止可派生提前。 时间轴以视口中心时间和连续缩放比例计算坐标,不创建覆盖历史与未来的巨大滚动画布。默认一天一格,最多一小时一格;基准格宽为 48~84 逻辑像素。平移没有预设日期边界,只绘制可见区域附近的网格和条目。顶部工具栏只保留单行并排的「今天」按钮与自适应宽度的缩放滑条,不显示当前日期和常驻的「时间/格」文本,缩放比例仅保留无障碍语义描述。表头基准高度为 24 逻辑像素:不显示小时时,第一行显示日期、第二行居中显示星期;显示小时时,第一行将「日期 星期」作为一个整体居中,第二行显示小时。标签宽度测量包含完整星期与中间空格,固定在对应时间格的中上方,以完整文字绘制并在视口边缘自然裁切,不贴靠屏幕边缘或变成省略号。首个日程条距表头分隔线 12 逻辑像素,与日程条之间的轨道间距一致。工具栏不再包含问号帮助图标及其提示。当前时间线单独在前景绘制,覆盖日程条但不拦截交互,也不绘制圆点。条目单击复用日程详情弹层,编辑、删除、同步后由功能控制器通知刷新。 diff --git a/docs/deadline-cutoff.md b/docs/deadline-cutoff.md index 7d7ea1e..1939712 100644 --- a/docs/deadline-cutoff.md +++ b/docs/deadline-cutoff.md @@ -3,9 +3,10 @@ ## 时间规则 设置默认关闭,预置每日 22:00–次日 08:00。开关、开始和结束分钟作为一份 -JSON 设置保存;关闭仍保留所选时间。时间选择页常驻原生 Material 表盘, -也保留开始/结束的 HH:mm 填写框;表盘可原生切换为键盘模式。公开控件的 -表盘草稿在点击“确定”时保存,取消只恢复当前选择,不退出设置页。 +JSON 设置保存;关闭仍保留所选时间。点击开始或结束时间后弹出与添加/编辑 +DDL 一致的原生 Material 时间选择器,复用 `appPickerDialogBuilder`,默认 +表盘并支持原生键盘切换;页面无独立文字输入框。点击“确定”保存对应时间,取消 +不写入设置;两者都只关闭表盘并返回设置页。 区间两端包含:开始早于结束时匹配同一天;开始晚于结束时匹配跨日区间, 凌晨截止归到前一天的开始时间。开始与结束相同时仅匹配该时间点,不代表 @@ -31,8 +32,9 @@ JSON 设置保存;关闭仍保留所选时间。时间选择页常驻原生 Ma 命中窗口的 DDL: -- 全局排版宽度 `D` 在设置中配置,默认 4 天、范围 1–30 天;统一覆盖旧的 - 单条拖尾设置,旧 `tailDays` 数据仍保留兼容。 +- 排版宽度 `D` 优先使用单条 DDL 的 `tailDays`(1–30 天)。未单独设置时 + 跟随全局默认值(初始为 4 天);旧记录中已保存的单条长度继续生效。 + 编辑页“更多选项”可调整拖尾滑条,或恢复“跟随全局设置”。 - 预留区间为 `[有效截止 − D 天, 真实截止]`。 - 左侧主体为最小缩放 `D − 0.25` 天的屏幕定宽,不随缩放增长(默认 3.75 天);右边界位于有效截止。 @@ -46,23 +48,21 @@ JSON 设置保存;关闭仍保留所选时间。时间选择页常驻原生 Ma ## 日程与考试的泳道样式 -普通日程和考试的左侧信息区同样使用全局 `D` 天预留及 `D − 0.25` 基线天 +普通日程和考试的左侧信息区使用全局默认 `D` 天预留及 `D − 0.25` 基线天 的屏幕定宽,整体占位为 `[真实开始 − D 天, 真实结束]`。真实时段由开始、 结束两条 3px 实线框住,其间是 45° 双色宽斜纹(实填充色与高亮色两带 全不透明交替,自开始标线起首带为实色),按实际时距缩放;左侧同样是 45° 斜纹(两带交替使用实填充色与其 0.5 半透明)及向内绘制的 1.5px 高亮色(accent)边框。两条时间线重合时结束线最后绘制。所有区域保持 视口裁剪,日期与真实时距不因左侧信息区预留而改变。设置中的“日程与 -考试信息栏”开关(默认开启)可隐藏普通日程和考试的信息栏文字、斜纹与 -边框,同时取消其左侧排版预留(占位改为 `[真实开始, 真实结束]`); -隐藏时绘制面仍向左额外保留一个线宽,开始标线按真实位置保持可见, -结束标线、时段斜纹和点击区域不受影响,DDL 与仅开始时间项目不受该 -开关影响。 +考试信息栏”开关(默认开启)可切换至旧版方框样式:文字显示在日程或 +考试方框内,取消左侧信息区预留,占位改为 `[真实开始, 真实结束]`。 +方框按真实时距缩放并裁剪到视口;DDL 与仅开始时间项目不受该开关影响。 ## 与主线滚动/性能实现整合 泳道先按上述完整区间分配一次,缩放只更新绘制投影,不重新分配泳道。 -普通 DDL 和日程左信息区保持定宽,真实时间段才按缩放伸缩;不能照搬 +普通 DDL 和日程左信息区按各自生效宽度保持定宽,真实时间段才按缩放伸缩;不能照搬 主线日历天数拖尾随缩放扩张或邻卡裁短信息区的规则。纵向范围仍取全局 最高泳道,不依赖横向可见条目。 diff --git a/lib/agenda/merge.dart b/lib/agenda/merge.dart index 55da0cc..3d1f89f 100644 --- a/lib/agenda/merge.dart +++ b/lib/agenda/merge.dart @@ -23,6 +23,7 @@ List _mergeDeadlineEntries( next.copyWith( done: saved.local ? entry.done || saved.done : entry.done, deleted: saved.deleted, + tailDays: saved.tailDays, ), ); } diff --git a/lib/agenda/model.dart b/lib/agenda/model.dart index bc6dc1c..d1067a6 100644 --- a/lib/agenda/model.dart +++ b/lib/agenda/model.dart @@ -30,7 +30,7 @@ class AgendaEntry { this.url, this.color, this.details, - this.tailDays = defaultTailDays, + this.tailDays, this.startOnly = false, this.deadlineCutoffWindow = const DeadlineCutoffWindow(), }) { @@ -44,7 +44,7 @@ class AgendaEntry { (endsAt == null || !endsAt!.isAfter(startsAt))) { throw const FormatException('结束时间必须晚于开始时间'); } - if (tailDays < 1 || tailDays > maxTailDays) { + if (tailDays case final days? when days < 1 || days > maxTailDays) { throw const FormatException('拖尾长度必须为 1–30 天'); } if (color != null && kind == AgendaKind.exam) { @@ -96,9 +96,11 @@ class AgendaEntry { final AgendaColor? color; final AgendaEntryDetails? details; - /// Legacy per-entry width retained for storage compatibility. Timeline layout - /// and fixed-screen information widths use the global layout-days setting. - final int tailDays; + /// An explicit per-entry width. Null follows the global layout-days setting. + final int? tailDays; + + int effectiveLayoutDays(int defaultDays) => + isDeadline ? tailDays ?? defaultDays : defaultDays; /// A deadline that only knows when it opened, with no due date at all — /// an open-ended quiz, for instance. It can never be overdue, and the @@ -153,6 +155,7 @@ class AgendaEntry { AgendaEntry copyWith({ bool? done, bool? deleted, + int? tailDays, DeadlineCutoffWindow? deadlineCutoffWindow, }) => AgendaEntry( id: id, @@ -172,7 +175,7 @@ class AgendaEntry { url: url, color: color, details: details, - tailDays: tailDays, + tailDays: tailDays ?? this.tailDays, startOnly: startOnly, deadlineCutoffWindow: deadlineCutoffWindow ?? this.deadlineCutoffWindow, ); @@ -250,7 +253,7 @@ class AgendaEntry { ? null : Uri.tryParse(rawUrl) ?? (throw FormatException('url 格式不正确')), color: color, - tailDays: jsonIntFieldOrNull(json, 'tailDays') ?? defaultTailDays, + tailDays: jsonIntFieldOrNull(json, 'tailDays'), // Entries written before this flag existed simply had no open-ended // deadlines, so a missing key means an ordinary deadline. startOnly: jsonBoolField(json, 'startOnly', fallback: false), diff --git a/lib/agenda/timeline_layout.dart b/lib/agenda/timeline_layout.dart index 6fdf890..841caa9 100644 --- a/lib/agenda/timeline_layout.dart +++ b/lib/agenda/timeline_layout.dart @@ -38,7 +38,8 @@ class AgendaTimelineLayout { required this.deadlineCardSpan, int layoutDays = AgendaEntry.defaultTailDays, this.reserveEventInformation = true, - }) : _reservations = _reserveLanes( + }) : _layoutDays = layoutDays, + _reservations = _reserveLanes( entries, minimumEventSpan: minimumEventSpan, layoutDays: layoutDays, @@ -46,6 +47,7 @@ class AgendaTimelineLayout { ); final Duration deadlineCardSpan; + final int _layoutDays; /// When the event information card is hidden, events reserve and paint only /// their real interval instead of an information-width lead on the left. @@ -56,8 +58,13 @@ class AgendaTimelineLayout { if (!zoom.isFinite || zoom < 1) { throw RangeError.value(zoom, 'zoom', 'Must be finite and at least one'); } - final fixedWidthSpan = Duration( - microseconds: (deadlineCardSpan.inMicroseconds / zoom).ceil(), + Duration fixedWidthSpan(AgendaEntry entry) => Duration( + microseconds: + (deadlineCardSpan.inMicroseconds * + (entry.effectiveLayoutDays(_layoutDays) - .25) / + (_layoutDays - .25) / + zoom) + .ceil(), ); return [ for (final interval in _reservations) @@ -72,11 +79,11 @@ class AgendaTimelineLayout { (!interval.entry.isDeadline && !reserveEventInformation) ? interval.entry.startsAt : (interval.entry.isDeadline - ? interval.entry.dueAt - : interval.entry.startsAt) - .subtract(fixedWidthSpan), + ? interval.entry.dueAt + : interval.entry.startsAt) + .subtract(fixedWidthSpan(interval.entry)), paintEnd: interval.entry.startOnly - ? interval.entry.startsAt.add(fixedWidthSpan) + ? interval.entry.startsAt.add(fixedWidthSpan(interval.entry)) : interval.end, ), ]; @@ -108,27 +115,40 @@ List<_LaneReservation> _reserveLanes( required bool reserveEventInformation, }) { if (layoutDays < 1 || layoutDays > AgendaEntry.maxTailDays) { - throw RangeError.range(layoutDays, 1, AgendaEntry.maxTailDays, 'layoutDays'); + throw RangeError.range( + layoutDays, + 1, + AgendaEntry.maxTailDays, + 'layoutDays', + ); } DateTime reservationStart(AgendaEntry entry) => entry.startOnly || (!entry.isDeadline && !reserveEventInformation) ? entry.startsAt - : (entry.isDeadline ? entry.dueAt : entry.startsAt) - .subtract(Duration(days: layoutDays)); + : (entry.isDeadline ? entry.dueAt : entry.startsAt).subtract( + Duration(days: entry.effectiveLayoutDays(layoutDays)), + ); DateTime reservationEnd(AgendaEntry entry) => entry.isDeadline ? entry.startOnly - ? entry.startsAt.add(Duration(days: layoutDays)) + ? entry.startsAt.add( + Duration(days: entry.effectiveLayoutDays(layoutDays)), + ) : entry.realDueAt : entry.endsAt!; - final intervals = [ - for (final entry in entries.where((entry) => !entry.deleted)) - (entry: entry, start: reservationStart(entry), end: reservationEnd(entry)), - ]..sort((a, b) { - final byStart = a.start.compareTo(b.start); - if (byStart != 0) return byStart; - final byEnd = a.end.compareTo(b.end); - return byEnd != 0 ? byEnd : a.entry.id.compareTo(b.entry.id); - }); + final intervals = + [ + for (final entry in entries.where((entry) => !entry.deleted)) + ( + entry: entry, + start: reservationStart(entry), + end: reservationEnd(entry), + ), + ]..sort((a, b) { + final byStart = a.start.compareTo(b.start); + if (byStart != 0) return byStart; + final byEnd = a.end.compareTo(b.end); + return byEnd != 0 ? byEnd : a.entry.id.compareTo(b.entry.id); + }); final laneEnds = []; final reservations = <_LaneReservation>[]; for (final interval in intervals) { diff --git a/lib/agenda/ui/agenda_editor.dart b/lib/agenda/ui/agenda_editor.dart index 15f3733..d859f4d 100644 --- a/lib/agenda/ui/agenda_editor.dart +++ b/lib/agenda/ui/agenda_editor.dart @@ -74,9 +74,7 @@ class _AgendaEditorState extends State<_AgendaEditor> { late final _title = TextEditingController(text: widget.entryTitle); late final _location = TextEditingController(text: widget.entry?.location); late final _notes = TextEditingController(text: widget.entryNotes); - // Preserve legacy per-entry metadata without offering an ineffective override. - late final int _tailDays = - widget.entry?.tailDays ?? AgendaEntry.defaultTailDays; + late int? _tailDays = widget.entry?.tailDays; late AgendaKind _kind = widget.kind; late DateTime _start = widget.entry?.startsAt ?? widget.startsAt; late DateTime _end = @@ -223,6 +221,10 @@ class _AgendaEditorState extends State<_AgendaEditor> { } } + void _updateTailDays(int? days) { + setState(() => _tailDays = days); + } + void _updateColor(AgendaColor? color) { setState(() => _color = color); } diff --git a/lib/agenda/ui/agenda_editor_sections.dart b/lib/agenda/ui/agenda_editor_sections.dart index 57eb081..1ff079f 100644 --- a/lib/agenda/ui/agenda_editor_sections.dart +++ b/lib/agenda/ui/agenda_editor_sections.dart @@ -134,6 +134,40 @@ extension _AgendaEditorSections on _AgendaEditorState { leading: const Icon(Icons.tune_rounded, size: 20), title: Text(l10n.agendaMoreOptions), children: [ + if (_kind == AgendaKind.deadline) ...[ + Text(l10n.agendaTailDays), + SwitchListTile( + key: const ValueKey('agenda-tail-days-default'), + contentPadding: EdgeInsets.zero, + title: Text(l10n.agendaTailDaysDefault), + value: _tailDays == null, + onChanged: _saving + ? null + : (useDefault) => _updateTailDays( + useDefault ? null : AgendaEntry.defaultTailDays, + ), + ), + if (_tailDays case final days?) ...[ + Semantics( + label: l10n.agendaTailDays, + child: Slider( + key: const ValueKey('agenda-tail-days-slider'), + value: days.toDouble(), + min: 1, + max: AgendaEntry.maxTailDays.toDouble(), + divisions: AgendaEntry.maxTailDays - 1, + label: l10n.agendaTailDaysValue(days), + semanticFormatterCallback: (value) => + l10n.agendaTailDaysValue(value.round()), + onChanged: _saving + ? null + : (value) => _updateTailDays(value.round()), + ), + ), + Text(l10n.agendaTailDaysValue(days), textAlign: TextAlign.center), + ], + const SizedBox(height: 12), + ], if (_kind != AgendaKind.deadline) ...[ TextFormField( controller: _location, diff --git a/lib/agenda/ui/agenda_timeline.dart b/lib/agenda/ui/agenda_timeline.dart index 29bd9fb..d049f49 100644 --- a/lib/agenda/ui/agenda_timeline.dart +++ b/lib/agenda/ui/agenda_timeline.dart @@ -262,12 +262,20 @@ class _AgendaTimelineState extends State void didUpdateWidget(covariant AgendaTimeline oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { - oldWidget.controller.settings.deadlineCutoff.removeListener(_settingsChanged); - oldWidget.controller.settings.agendaLayoutDays.removeListener(_settingsChanged); - oldWidget.controller.settings.agendaEventInfoBar.removeListener(_settingsChanged); + oldWidget.controller.settings.deadlineCutoff.removeListener( + _settingsChanged, + ); + oldWidget.controller.settings.agendaLayoutDays.removeListener( + _settingsChanged, + ); + oldWidget.controller.settings.agendaEventInfoBar.removeListener( + _settingsChanged, + ); widget.controller.settings.deadlineCutoff.addListener(_settingsChanged); widget.controller.settings.agendaLayoutDays.addListener(_settingsChanged); - widget.controller.settings.agendaEventInfoBar.addListener(_settingsChanged); + widget.controller.settings.agendaEventInfoBar.addListener( + _settingsChanged, + ); _cachedEntries = null; } if (oldWidget.filter != widget.filter) { @@ -316,8 +324,12 @@ class _AgendaTimelineState extends State void dispose() { _stopMotion(holdVertical: true); widget.controller.settings.deadlineCutoff.removeListener(_settingsChanged); - widget.controller.settings.agendaLayoutDays.removeListener(_settingsChanged); - widget.controller.settings.agendaEventInfoBar.removeListener(_settingsChanged); + widget.controller.settings.agendaLayoutDays.removeListener( + _settingsChanged, + ); + widget.controller.settings.agendaEventInfoBar.removeListener( + _settingsChanged, + ); _horizontalFling.dispose(); _releaseVerticalHold(); _lanes.dispose(); @@ -370,7 +382,8 @@ class _AgendaTimelineState extends State ? l10n.agendaTimelineOneHour : l10n.agendaTimelineHours(hoursPerCell.toStringAsFixed(1)); final deadlineCardWidth = - cellWidth * (widget.controller.settings.agendaLayoutDays.value - .25); + cellWidth * + (widget.controller.settings.agendaLayoutDays.value - .25); final deadlineCardSpan = Duration( microseconds: (deadlineCardWidth / baseline * _microsecondsPerHour) .ceil(), @@ -637,7 +650,8 @@ class _AgendaTimelineState extends State final right = _timeAt(width, width, pixelsPerHour); final placementsInTime = []; final laneCount = placements.fold( - 0, (count, placement) => math.max(count, placement.lane + 1), + 0, + (count, placement) => math.max(count, placement.lane + 1), ); for (final placement in placements) { if (placement.paintEnd.isAfter(left) && @@ -676,13 +690,17 @@ class _AgendaTimelineState extends State builder: (context, laneConstraints) { final height = laneConstraints.maxHeight; final contentHeight = math.max( - height, laneCount * laneHeight + 12, + height, + laneCount * laneHeight + 12, ); final extent = contentHeight - height; - final previousOffset = _lanes.hasClients ? _lanes.offset : 0.0; + final previousOffset = _lanes.hasClients + ? _lanes.offset + : 0.0; // Bounce overscroll belongs to ScrollPosition. Only a changed // range/viewport invalidates a previously valid culling offset. - final dimensionsChanged = _lastContentExtent != extent || + final dimensionsChanged = + _lastContentExtent != extent || _lastViewportHeight != height; _lastContentExtent = extent; _lastViewportHeight = height; @@ -779,22 +797,21 @@ class _AgendaTimelineState extends State final entry = placement.entry; final cellWidth = (width / 5).clamp(80.0, 140.0).toDouble() * .6; final deadlineWidth = - cellWidth * (widget.controller.settings.agendaLayoutDays.value - .25); + cellWidth * + (entry.effectiveLayoutDays( + widget.controller.settings.agendaLayoutDays.value, + ) - + .25); double x(DateTime time) => width / 2 + time.difference(_center).inMicroseconds / _microsecondsPerHour * pixelsPerHour; - if (!entry.isDeadline) { - final showInformation = widget.controller.settings - .agendaEventInfoBar.value; + if (!entry.isDeadline && + widget.controller.settings.agendaEventInfoBar.value) { final start = x(entry.startsAt); final end = x(entry.endsAt!); - // Hidden, the surface still extends one line width left of the real - // start so the start indicator stays painted at its true position. - final informationLeft = showInformation - ? start - deadlineWidth - : start - eventTimelineLineWidth; + final informationLeft = start - deadlineWidth; final left = math.max(0.0, informationLeft); final right = math.min(width, end); return Positioned( @@ -807,13 +824,13 @@ class _AgendaTimelineState extends State entry: entry, now: widget.now, informationWidth: deadlineWidth, - showInformation: showInformation, geometry: EventTimelineGeometry( informationLeft: informationLeft - left, startX: start - left, endX: end - left, ), - onTap: () => showAgendaEntry(context, widget.controller.agenda, entry), + onTap: () => + showAgendaEntry(context, widget.controller.agenda, entry), ), ); } @@ -841,7 +858,8 @@ class _AgendaTimelineState extends State effectiveX: effective - left, realX: real - left, ), - onTap: () => showAgendaEntry(context, widget.controller.agenda, entry), + onTap: () => + showAgendaEntry(context, widget.controller.agenda, entry), ), ); } @@ -855,7 +873,9 @@ class _AgendaTimelineState extends State : x(placement.start); // Crop long ordinary events before creating widgets; no huge canvas/boxes. final left = entry.isDeadline ? actualLeft : math.max(0.0, actualLeft); - final barWidth = deadlineWidth; + final barWidth = entry.isDeadline + ? deadlineWidth + : math.max(0.0, math.min(width, actualRight) - left); final tapWidth = math.max(24.0, barWidth); var card = _plainCards[entry.id]; if (card == null || diff --git a/lib/app/ui/deadline_cutoff_settings.dart b/lib/app/ui/deadline_cutoff_settings.dart index df30f4c..b933a5a 100644 --- a/lib/app/ui/deadline_cutoff_settings.dart +++ b/lib/app/ui/deadline_cutoff_settings.dart @@ -1,10 +1,9 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import '../../agenda/deadline_cutoff.dart'; import '../../l10n/l10n.dart'; import '../../ui/app_page.dart'; +import '../../ui/dialog_layout.dart'; import '../settings_controller.dart'; String _clock(BuildContext context, int minute) => @@ -13,10 +12,6 @@ String _clock(BuildContext context, int minute) => alwaysUse24HourFormat: true, ); -String _inputClock(int minute) => - '${(minute ~/ 60).toString().padLeft(2, '0')}:' - '${(minute % 60).toString().padLeft(2, '0')}'; - String _windowSummary(BuildContext context, DeadlineCutoffWindow window) { final l10n = context.l10n; final start = _clock(context, window.startMinute); @@ -57,354 +52,89 @@ class DeadlineCutoffSettingsTile extends StatelessWidget { ); } -class DeadlineCutoffScreen extends StatefulWidget { +class DeadlineCutoffScreen extends StatelessWidget { const DeadlineCutoffScreen({super.key, required this.settings}); final AppSettingsController settings; - @override - State createState() => _DeadlineCutoffScreenState(); -} - -class _DeadlineCutoffScreenState extends State { - late DeadlineCutoffWindow _window; - late final TextEditingController _start; - late final TextEditingController _end; - final _startFocus = FocusNode(); - final _endFocus = FocusNode(); - bool _editingStart = true; - bool _startInvalid = false; - bool _endInvalid = false; - - @override - void initState() { - super.initState(); - _window = widget.settings.deadlineCutoff.value; - _start = TextEditingController(text: _inputClock(_window.startMinute)); - _end = TextEditingController(text: _inputClock(_window.endMinute)); - _startFocus.addListener(_startFocusChanged); - _endFocus.addListener(_endFocusChanged); - widget.settings.deadlineCutoff.addListener(_settingsChanged); - } - - @override - void didUpdateWidget(covariant DeadlineCutoffScreen oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.settings != widget.settings) { - oldWidget.settings.deadlineCutoff.removeListener(_settingsChanged); - widget.settings.deadlineCutoff.addListener(_settingsChanged); - _syncInputs(widget.settings.deadlineCutoff.value); - } - } - - void _syncInputs(DeadlineCutoffWindow next) { - // A switch or opposite endpoint change must not destroy partially typed - // input. Only a changed persisted endpoint replaces its own text draft. - if (next.startMinute != _window.startMinute) { - _start.text = _inputClock(next.startMinute); - _startInvalid = false; - } - if (next.endMinute != _window.endMinute) { - _end.text = _inputClock(next.endMinute); - _endInvalid = false; - } - _window = next; - } - - void _settingsChanged() { - if (!mounted) return; - setState(() => _syncInputs(widget.settings.deadlineCutoff.value)); - } - - void _startFocusChanged() => _focusChanged(start: true); - void _endFocusChanged() => _focusChanged(start: false); - - void _focusChanged({required bool start}) { - final focus = start ? _startFocus : _endFocus; - if (!mounted) return; - if (focus.hasFocus) { - if (_editingStart != start) setState(() => _editingStart = start); - } else { - _commitInput(start: start); - } - } - - void _commitInput({required bool start}) { - if (!mounted) return; - final controller = start ? _start : _end; - final match = RegExp(r'^\s*(\d{1,2}):(\d{2})\s*$') - .firstMatch(controller.text); - final hour = match == null ? null : int.tryParse(match[1]!); - final minute = match == null ? null : int.tryParse(match[2]!); - final valid = hour != null && hour < 24 && minute != null && minute < 60; - setState(() { - if (start) { - _startInvalid = !valid; - } else { - _endInvalid = !valid; - } - }); - if (hour == null || hour >= 24 || minute == null || minute >= 60) return; - final value = hour * 60 + minute; - controller.text = _inputClock(value); - final latest = widget.settings.deadlineCutoff.value; - if (value == (start ? latest.startMinute : latest.endMinute)) return; - unawaited(widget.settings.setDeadlineCutoff( - start - ? latest.copyWith(startMinute: value) - : latest.copyWith(endMinute: value), - )); - } - - void _resetInput(int value, {required bool start}) { - setState(() { - if (start) { - _start.text = _inputClock(value); - _startInvalid = false; - } else { - _end.text = _inputClock(value); - _endInvalid = false; - } - }); - } - - void _cancelTime({required bool start}) { - final latest = widget.settings.deadlineCutoff.value; - _resetInput( - start ? latest.startMinute : latest.endMinute, - start: start, + Future _selectTime(BuildContext context, {required bool start}) async { + final window = settings.deadlineCutoff.value; + final minute = start ? window.startMinute : window.endMinute; + final selected = await showTimePicker( + context: context, + builder: appPickerDialogBuilder, + initialTime: TimeOfDay(hour: minute ~/ 60, minute: minute % 60), + helpText: start + ? context.l10n.deadlineCutoffStart + : context.l10n.deadlineCutoffEnd, ); - } - - Future _selectTime(TimeOfDay selected, {required bool start}) { + if (selected == null || !context.mounted) return; + // Preserve changes to the switch or opposite endpoint while the dial is open. + final latest = settings.deadlineCutoff.value; final value = selected.hour * 60 + selected.minute; - _resetInput(value, start: start); - // The picker can remain mounted while enabled or the opposite endpoint - // changes. Read the latest configuration, never its opening snapshot. - final latest = widget.settings.deadlineCutoff.value; - return widget.settings.setDeadlineCutoff( + await settings.setDeadlineCutoff( start ? latest.copyWith(startMinute: value) : latest.copyWith(endMinute: value), ); } - void _selectEndpoint(bool start) { - FocusScope.of(context).unfocus(); - setState(() => _editingStart = start); - } - - @override - void dispose() { - widget.settings.deadlineCutoff.removeListener(_settingsChanged); - _startFocus.removeListener(_startFocusChanged); - _endFocus.removeListener(_endFocusChanged); - _startFocus.dispose(); - _endFocus.dispose(); - _start.dispose(); - _end.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { final l10n = context.l10n; - final editingStart = _editingStart; - final hintStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ); - Widget input({required bool start}) => TextField( - key: ValueKey(start ? 'deadline-cutoff-start' : 'deadline-cutoff-end'), - controller: start ? _start : _end, - focusNode: start ? _startFocus : _endFocus, - keyboardType: TextInputType.datetime, - textInputAction: TextInputAction.done, - decoration: InputDecoration( - labelText: start ? l10n.deadlineCutoffStart : l10n.deadlineCutoffEnd, - hintText: 'HH:mm', - prefixIcon: const Icon(Icons.schedule_rounded), - errorText: (start ? _startInvalid : _endInvalid) - ? MaterialLocalizations.of(context).invalidTimeLabel - : null, - ), - onSubmitted: (_) => _commitInput(start: start), - ); return AppRoutePage( title: l10n.settingsDeadlineCutoff, - child: SingleChildScrollView( - padding: const EdgeInsets.only(top: 12, bottom: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SwitchListTile( - key: const ValueKey('deadline-cutoff-enabled'), - contentPadding: EdgeInsets.zero, - title: Text(l10n.deadlineCutoffEnabled), - subtitle: Text(l10n.deadlineCutoffDescription), - value: _window.enabled, - onChanged: (enabled) => widget.settings.setDeadlineCutoff( - widget.settings.deadlineCutoff.value.copyWith( - enabled: enabled, - ), - ), - ), - const SizedBox(height: 12), - Text( - _windowSummary(context, _window), - key: const ValueKey('deadline-cutoff-summary'), - style: hintStyle, - ), - const SizedBox(height: 20), - input(start: true), - const SizedBox(height: 16), - input(start: false), - const SizedBox(height: 20), - SegmentedButton( - key: const ValueKey('deadline-cutoff-endpoint'), - showSelectedIcon: false, - segments: [ - ButtonSegment( - value: true, - label: Text( - l10n.deadlineCutoffStart, - textAlign: TextAlign.center, - ), - ), - ButtonSegment( - value: false, - label: Text( - l10n.deadlineCutoffEnd, - textAlign: TextAlign.center, - ), - ), - ], - selected: {_editingStart}, - onSelectionChanged: (selection) => - _selectEndpoint(selection.single), - ), - const SizedBox(height: 8), - ], - ), + child: ValueListenableBuilder( + valueListenable: settings.deadlineCutoff, + builder: (context, window, _) { + Widget timeTile({required bool start}) => ListTile( + key: ValueKey( + start ? 'deadline-cutoff-start' : 'deadline-cutoff-end', ), - InlineDeadlineTimePicker( - key: const ValueKey('deadline-cutoff-clock'), - minute: _editingStart ? _window.startMinute : _window.endMinute, - endpoint: _editingStart, - helpText: _editingStart - ? l10n.deadlineCutoffStart - : l10n.deadlineCutoffEnd, - onSelected: (value) => _selectTime(value, start: editingStart), - onCancelled: () => _cancelTime(start: editingStart), + leading: const Icon(Icons.schedule_rounded), + title: Text( + start ? l10n.deadlineCutoffStart : l10n.deadlineCutoffEnd, ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 12), - Text(l10n.deadlineCutoffBoundaryHint, style: hintStyle), - const SizedBox(height: 8), - Text(l10n.deadlineCutoffPreserveHint, style: hintStyle), - ], + trailing: Text( + _clock(context, start ? window.startMinute : window.endMinute), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontFeatures: const [FontFeature.tabularFigures()], ), ), - ], - ), - ), - ); - } -} - -/// Embeds the real Material time picker, without copying its private dial. -/// -/// TimePickerDialog reports OK/Cancel through Navigator.pop. The nested -/// navigator has an inert anchor below its picker, so those actions never pop -/// the settings route. Each result resets the picker, keeping it resident. -class InlineDeadlineTimePicker extends StatefulWidget { - const InlineDeadlineTimePicker({ - super.key, - required this.minute, - required this.endpoint, - required this.helpText, - required this.onSelected, - required this.onCancelled, - }); - - final int minute; - final bool endpoint; - final String helpText; - final Future Function(TimeOfDay value) onSelected; - final VoidCallback onCancelled; - - @override - State createState() => _InlineDeadlineTimePickerState(); -} - -class _InlineDeadlineTimePickerState extends State { - int _generation = 0; - - @override - void didUpdateWidget(covariant InlineDeadlineTimePicker oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.minute != widget.minute || oldWidget.endpoint != widget.endpoint) { - _generation++; - } - } - - void _complete(TimeOfDay? selected, int generation) { - if (!mounted || generation != _generation) return; - if (selected != null) { - unawaited(widget.onSelected(selected)); - } else { - widget.onCancelled(); - } - setState(() => _generation++); - } - - Route _pickerRoute(int generation) { - final route = PageRouteBuilder( - settings: const RouteSettings(name: 'deadline-cutoff-inline-picker'), - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, - pageBuilder: (context, animation, secondaryAnimation) => TimePickerDialog( - initialTime: TimeOfDay(hour: widget.minute ~/ 60, minute: widget.minute % 60), - helpText: widget.helpText, - initialEntryMode: TimePickerEntryMode.dial, - orientation: Orientation.portrait, + onTap: () => _selectTime(context, start: start), + ); + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SwitchListTile( + key: const ValueKey('deadline-cutoff-enabled'), + contentPadding: EdgeInsets.zero, + title: Text(l10n.deadlineCutoffEnabled), + subtitle: Text(l10n.deadlineCutoffDescription), + value: window.enabled, + onChanged: (enabled) => settings.setDeadlineCutoff( + settings.deadlineCutoff.value.copyWith(enabled: enabled), + ), + ), + const SizedBox(height: 12), + timeTile(start: true), + timeTile(start: false), + const SizedBox(height: 12), + Text( + _windowSummary(context, window), + key: const ValueKey('deadline-cutoff-summary'), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + }, ), ); - unawaited(route.popped.then((selected) => _complete(selected, generation))); - return route; } - - @override - Widget build(BuildContext context) => SizedBox( - height: 580.0 * - (MediaQuery.textScalerOf(context).scale(14) / 14).clamp(1.0, 1.1), - child: MediaQuery.removeViewInsets( - context: context, - removeBottom: true, - child: HeroControllerScope.none( - child: Navigator( - key: ValueKey(_generation), - requestFocus: false, - onGenerateInitialRoutes: (_, initialRoute) => [ - PageRouteBuilder( - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, - pageBuilder: (context, animation, secondaryAnimation) => - const SizedBox.shrink(), - ), - _pickerRoute(_generation), - ], - onGenerateRoute: (_) => _pickerRoute(_generation), - ), - ), - ), - ); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ce407b1..483fea9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -658,7 +658,8 @@ "agendaColorPresets": "Quick colors", "agendaColorCustomSection": "Custom color", "agendaTailDays": "Tail length (days)", - "agendaTailDaysHint": "1–30 days; default: 4 days", + "agendaTailDaysHint": "1–30 days; per-item settings take priority", + "agendaTailDaysDefault": "Use global default", "agendaTailDaysValue": "{days, plural, =1{1 day} other{{days} days}}", "@agendaTailDaysValue": { "placeholders": { @@ -1749,7 +1750,7 @@ "timetableHeightHint": "Fit to screen shows every period at once. Normal height keeps comfortable rows and vertical scrolling.", "settingsDeadlineCutoff": "Deadline cutoff window", "deadlineCutoffEnabled": "Enable daily cutoff window", - "deadlineCutoffDescription": "Move DDLs within this window to its start. Events, exams and open-ended quizzes are unaffected.", + "deadlineCutoffDescription": "Show DDLs in this window as due at its start. Actual deadlines stay unchanged.", "deadlineCutoffStart": "Start time", "deadlineCutoffEnd": "End time", "deadlineCutoffDisabled": "Not enabled", @@ -1775,7 +1776,7 @@ } } }, - "deadlineCutoffInstant": "Only the instant {time} each day (not all day)", + "deadlineCutoffInstant": "Only at {time} each day", "@deadlineCutoffInstant": { "placeholders": { "time": { @@ -1804,11 +1805,11 @@ } }, "settingsAgendaLayoutDays": "DDL layout width", - "agendaLayoutDaysDescription": "Use one layout width for deadlines, start-only items, and the left side of adjusted deadlines, overriding old per-item tails. Painted tails keep a fixed screen width.", + "agendaLayoutDaysDescription": "Default layout width. Individual DDL tail lengths take priority.", "agendaLayoutDaysUnit": "days", "agendaLayoutDaysRange": "Enter a whole number from 1 to 30", "settingsAgendaEventInfoBar": "Event information bar", - "settingsAgendaEventInfoBarDescription": "Show the left information card for events and exams in the timeline. When off, its layout space is released; boundary lines, the filled interval, and deadlines are unaffected.", + "settingsAgendaEventInfoBarDescription": "Show details on the left when on, or inside event and exam boxes when off.", "releaseVersion": "{channel} {version}", "@releaseVersion": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 47558e5..5071049 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1634,9 +1634,15 @@ abstract class AppLocalizations { /// No description provided for @agendaTailDaysHint. /// /// In en, this message translates to: - /// **'1–30 days; default: 4 days'** + /// **'1–30 days; per-item settings take priority'** String get agendaTailDaysHint; + /// No description provided for @agendaTailDaysDefault. + /// + /// In en, this message translates to: + /// **'Use global default'** + String get agendaTailDaysDefault; + /// No description provided for @agendaTailDaysValue. /// /// In en, this message translates to: @@ -4341,7 +4347,7 @@ abstract class AppLocalizations { /// No description provided for @deadlineCutoffDescription. /// /// In en, this message translates to: - /// **'Move DDLs within this window to its start. Events, exams and open-ended quizzes are unaffected.'** + /// **'Show DDLs in this window as due at its start. Actual deadlines stay unchanged.'** String get deadlineCutoffDescription; /// No description provided for @deadlineCutoffStart. @@ -4377,7 +4383,7 @@ abstract class AppLocalizations { /// No description provided for @deadlineCutoffInstant. /// /// In en, this message translates to: - /// **'Only the instant {time} each day (not all day)'** + /// **'Only at {time} each day'** String deadlineCutoffInstant(String time); /// No description provided for @deadlineCutoffBoundaryHint. @@ -4425,7 +4431,7 @@ abstract class AppLocalizations { /// No description provided for @agendaLayoutDaysDescription. /// /// In en, this message translates to: - /// **'Use one layout width for deadlines, start-only items, and the left side of adjusted deadlines, overriding old per-item tails. Painted tails keep a fixed screen width.'** + /// **'Default layout width. Individual DDL tail lengths take priority.'** String get agendaLayoutDaysDescription; /// No description provided for @agendaLayoutDaysUnit. @@ -4449,7 +4455,7 @@ abstract class AppLocalizations { /// No description provided for @settingsAgendaEventInfoBarDescription. /// /// In en, this message translates to: - /// **'Show the left information card for events and exams in the timeline. When off, its layout space is released; boundary lines, the filled interval, and deadlines are unaffected.'** + /// **'Show details on the left when on, or inside event and exam boxes when off.'** String get settingsAgendaEventInfoBarDescription; /// No description provided for @releaseVersion. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ecb9f7d..4055fe3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -930,7 +930,10 @@ class AppLocalizationsEn extends AppLocalizations { String get agendaTailDays => 'Tail length (days)'; @override - String get agendaTailDaysHint => '1–30 days; default: 4 days'; + String get agendaTailDaysHint => '1–30 days; per-item settings take priority'; + + @override + String get agendaTailDaysDefault => 'Use global default'; @override String agendaTailDaysValue(int days) { @@ -2514,7 +2517,8 @@ class AppLocalizationsEn extends AppLocalizations { String get deadlineCutoffEnabled => 'Enable daily cutoff window'; @override - String get deadlineCutoffDescription => 'Move DDLs within this window to its start. Events, exams and open-ended quizzes are unaffected.'; + String get deadlineCutoffDescription => + 'Show DDLs in this window as due at its start. Actual deadlines stay unchanged.'; @override String get deadlineCutoffStart => 'Start time'; @@ -2537,14 +2541,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String deadlineCutoffInstant(String time) { - return 'Only the instant $time each day (not all day)'; + return 'Only at $time each day'; } @override - String get deadlineCutoffBoundaryHint => 'Both endpoints are included. An end before the start crosses midnight; equal times match only one instant.'; + String get deadlineCutoffBoundaryHint => + 'Both endpoints are included. An end before the start crosses midnight; equal times match only one instant.'; @override - String get deadlineCutoffPreserveHint => 'Actual deadlines stay unchanged. Turning this off retains your selected start and end times.'; + String get deadlineCutoffPreserveHint => + 'Actual deadlines stay unchanged. Turning this off retains your selected start and end times.'; @override String get agendaAdjustedDeadline => 'Adjusted deadline'; @@ -2566,7 +2572,8 @@ class AppLocalizationsEn extends AppLocalizations { String get settingsAgendaLayoutDays => 'DDL layout width'; @override - String get agendaLayoutDaysDescription => 'Use one layout width for deadlines, start-only items, and the left side of adjusted deadlines, overriding old per-item tails. Painted tails keep a fixed screen width.'; + String get agendaLayoutDaysDescription => + 'Default layout width. Individual DDL tail lengths take priority.'; @override String get agendaLayoutDaysUnit => 'days'; @@ -2578,7 +2585,8 @@ class AppLocalizationsEn extends AppLocalizations { String get settingsAgendaEventInfoBar => 'Event information bar'; @override - String get settingsAgendaEventInfoBarDescription => 'Show the left information card for events and exams in the timeline. When off, its layout space is released; boundary lines, the filled interval, and deadlines are unaffected.'; + String get settingsAgendaEventInfoBarDescription => + 'Show details on the left when on, or inside event and exam boxes when off.'; @override String releaseVersion(String channel, String version) { diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 92ce4a8..d5c6fd1 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -880,7 +880,10 @@ class AppLocalizationsZh extends AppLocalizations { String get agendaTailDays => '拖尾长度(天)'; @override - String get agendaTailDaysHint => '1–30 天,默认 4 天'; + String get agendaTailDaysHint => '1–30 天,单条设置优先'; + + @override + String get agendaTailDaysDefault => '跟随全局设置'; @override String agendaTailDaysValue(int days) { @@ -2400,7 +2403,7 @@ class AppLocalizationsZh extends AppLocalizations { String get deadlineCutoffEnabled => '启用每日伪提前'; @override - String get deadlineCutoffDescription => '将时间段内的 DDL 伪提前至该段起点;不影响普通日程、考试或无截止日期测验。'; + String get deadlineCutoffDescription => '将时段内的 DDL 提前至开始时间,真实截止时间不变。'; @override String get deadlineCutoffStart => '开始时间'; @@ -2423,14 +2426,16 @@ class AppLocalizationsZh extends AppLocalizations { @override String deadlineCutoffInstant(String time) { - return '仅每天 $time 这一时刻(不是全天)'; + return '仅每天 $time'; } @override - String get deadlineCutoffBoundaryHint => '开始和结束时刻均包含在区间内。结束早于开始时表示跨日;两者相同时仅匹配单个时刻。'; + String get deadlineCutoffBoundaryHint => + '开始和结束时刻均包含在区间内。结束早于开始时表示跨日;两者相同时仅匹配单个时刻。'; @override - String get deadlineCutoffPreserveHint => '真实截止时间保持不变。关闭后不再伪提前,但会保留已选择的开始和结束时间。'; + String get deadlineCutoffPreserveHint => + '真实截止时间保持不变。关闭后不再伪提前,但会保留已选择的开始和结束时间。'; @override String get agendaAdjustedDeadline => '伪截止'; @@ -2452,7 +2457,7 @@ class AppLocalizationsZh extends AppLocalizations { String get settingsAgendaLayoutDays => 'DDL 排版宽度'; @override - String get agendaLayoutDaysDescription => '统一控制 DDL、仅开始时间项目及伪提前 DDL 左侧信息区的排版宽度,覆盖原单条拖尾设置;渲染仍保持屏幕定宽。'; + String get agendaLayoutDaysDescription => '默认排版宽度;单个 DDL 的拖尾长度优先。'; @override String get agendaLayoutDaysUnit => '天'; @@ -2464,7 +2469,7 @@ class AppLocalizationsZh extends AppLocalizations { String get settingsAgendaEventInfoBar => '日程与考试信息栏'; @override - String get settingsAgendaEventInfoBarDescription => '控制泳道图中普通日程和考试左侧的信息栏是否显示;关闭后不再预留排版空间,开始与结束标线及中间时段不受影响。DDL 不受此开关影响。'; + String get settingsAgendaEventInfoBarDescription => '开启显示左侧信息栏,关闭使用方框样式。'; @override String releaseVersion(String channel, String version) { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 45a1152..96ccbc1 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -260,7 +260,8 @@ "agendaColorPresets": "快捷颜色", "agendaColorCustomSection": "自定义颜色", "agendaTailDays": "拖尾长度(天)", - "agendaTailDaysHint": "1–30 天,默认 4 天", + "agendaTailDaysHint": "1–30 天,单条设置优先", + "agendaTailDaysDefault": "跟随全局设置", "agendaTailDaysValue": "{days} 天", "agendaEndAfterStart": "结束时间需要晚于开始时间", "agendaReminderOverdue": "{time} 已逾期", @@ -710,13 +711,13 @@ "timetableHeightHint": "适应屏幕可一屏总览全部节次;正常高度保留舒适行高和纵向滚动。", "settingsDeadlineCutoff": "DDL 伪提前", "deadlineCutoffEnabled": "启用每日伪提前", - "deadlineCutoffDescription": "将时间段内的 DDL 伪提前至该段起点;不影响普通日程、考试或无截止日期测验。", + "deadlineCutoffDescription": "将时段内的 DDL 提前至开始时间,真实截止时间不变。", "deadlineCutoffStart": "开始时间", "deadlineCutoffEnd": "结束时间", "deadlineCutoffDisabled": "未启用", "deadlineCutoffSameDay": "每天 {start}–{end}", "deadlineCutoffOvernight": "每天 {start} 至次日 {end}", - "deadlineCutoffInstant": "仅每天 {time} 这一时刻(不是全天)", + "deadlineCutoffInstant": "仅每天 {time}", "deadlineCutoffBoundaryHint": "开始和结束时刻均包含在区间内。结束早于开始时表示跨日;两者相同时仅匹配单个时刻。", "deadlineCutoffPreserveHint": "真实截止时间保持不变。关闭后不再伪提前,但会保留已选择的开始和结束时间。", "agendaAdjustedDeadline": "伪截止", @@ -724,10 +725,10 @@ "agendaCutoffOriginal": "真实截止:{time}", "agendaCutoffAdjusted": "伪截止:{time}", "settingsAgendaLayoutDays": "DDL 排版宽度", - "agendaLayoutDaysDescription": "统一控制 DDL、仅开始时间项目及伪提前 DDL 左侧信息区的排版宽度,覆盖原单条拖尾设置;渲染仍保持屏幕定宽。", + "agendaLayoutDaysDescription": "默认排版宽度;单个 DDL 的拖尾长度优先。", "agendaLayoutDaysUnit": "天", "agendaLayoutDaysRange": "请输入 1–30 的整数", "settingsAgendaEventInfoBar": "日程与考试信息栏", - "settingsAgendaEventInfoBarDescription": "控制泳道图中普通日程和考试左侧的信息栏是否显示;关闭后不再预留排版空间,开始与结束标线及中间时段不受影响。DDL 不受此开关影响。", + "settingsAgendaEventInfoBarDescription": "开启显示左侧信息栏,关闭使用方框样式。", "releaseVersion": "{channel} {version}" } diff --git a/test/agenda_adjusted_timeline_test.dart b/test/agenda_adjusted_timeline_test.dart index f77e8d8..2edcbe5 100644 --- a/test/agenda_adjusted_timeline_test.dart +++ b/test/agenda_adjusted_timeline_test.dart @@ -79,26 +79,26 @@ Future _pixels(WidgetTester tester, GlobalKey key) async => final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; final image = await boundary.toImage(pixelRatio: 1); - final data = await image.toByteData( - format: ui.ImageByteFormat.rawRgba, - ); + final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba); image.dispose(); return data!; }))!; void main() { - test('Adjusted reservations use four days and include the real interval', () { - final entry = _deadline(tailDays: 14); - final placement = layoutAgendaTimeline( - [entry], - minimumEventSpan: const Duration(minutes: 1), - ).single; - expect(placement.start, entry.dueAt.subtract(const Duration(days: 4))); - expect(placement.end, entry.realDueAt); - expect(placement.layoutEnd, entry.realDueAt); - expect(entry.startsAt, _date.add(const Duration(hours: 14))); - expect(entry.tailDays, 14); - }); + test( + 'Adjusted reservations use the per-item tail and include the real interval', + () { + final entry = _deadline(tailDays: 14); + final placement = layoutAgendaTimeline([ + entry, + ], minimumEventSpan: const Duration(minutes: 1)).single; + expect(placement.start, entry.dueAt.subtract(const Duration(days: 14))); + expect(placement.end, entry.realDueAt); + expect(placement.layoutEnd, entry.realDueAt); + expect(entry.startsAt, _date.add(const Duration(hours: 14))); + expect(entry.tailDays, 14); + }, + ); test('An event inside the extension must occupy another lane', () { final ddl = _deadline(); @@ -109,39 +109,42 @@ void main() { startsAt: _date.add(const Duration(hours: 11)), endsAt: _date.add(const Duration(hours: 12)), ); - final placements = layoutAgendaTimeline( - [ddl, event], - minimumEventSpan: const Duration(minutes: 1), - ); + final placements = layoutAgendaTimeline([ + ddl, + event, + ], minimumEventSpan: const Duration(minutes: 1)); expect(placements.map((p) => p.lane).toSet(), hasLength(2)); }); - test('Unadjusted and start-only reservations use the global width', () { - for (final startOnly in [false, true]) { - final entry = _deadline( - tailDays: 14, - startOnly: startOnly, - window: const DeadlineCutoffWindow(), - ); - final placement = layoutAgendaTimeline( - [entry], - minimumEventSpan: const Duration(minutes: 1), - layoutDays: 14, - ).single; - expect( - placement.start, - startOnly - ? entry.startsAt - : entry.dueAt.subtract(const Duration(days: 14)), - ); - expect( - placement.end, - startOnly - ? entry.startsAt.add(const Duration(days: 14)) - : entry.realDueAt, - ); - } - }); + test( + 'Unadjusted and start-only reservations prioritize the per-item width', + () { + for (final startOnly in [false, true]) { + final entry = _deadline( + tailDays: 14, + startOnly: startOnly, + window: const DeadlineCutoffWindow(), + ); + final placement = layoutAgendaTimeline( + [entry], + minimumEventSpan: const Duration(minutes: 1), + layoutDays: 8, + ).single; + expect( + placement.start, + startOnly + ? entry.startsAt + : entry.dueAt.subtract(const Duration(days: 14)), + ); + expect( + placement.end, + startOnly + ? entry.startsAt.add(const Duration(days: 14)) + : entry.realDueAt, + ); + } + }, + ); testWidgets('Adjusted DDL cards use the compact Chinese deadline label', ( tester, @@ -165,24 +168,25 @@ void main() { final entry = _deadline(tailDays: 14); await _showTimeline(tester, [entry]); final before = tester.getRect(_body(entry)); - final geometryBefore = - tester.widget(_surface(entry)).geometry; - expect(before.width, closeTo(48 * 3.75, .001)); - expect( - geometryBefore.realX - geometryBefore.effectiveX, - closeTo(8, .001), - ); + final geometryBefore = tester + .widget(_surface(entry)) + .geometry; + expect(before.width, closeTo(48 * 13.75, .001)); + expect(geometryBefore.realX - geometryBefore.effectiveX, closeTo(8, .001)); expect( before.right, - closeTo(tester.getTopLeft(_surface(entry)).dx + - geometryBefore.effectiveX, .001), + closeTo( + tester.getTopLeft(_surface(entry)).dx + geometryBefore.effectiveX, + .001, + ), ); tester.widget(find.byType(Slider)).onChanged!(24); await tester.pumpAndSettle(); final after = tester.getRect(_body(entry)); - final geometryAfter = - tester.widget(_surface(entry)).geometry; + final geometryAfter = tester + .widget(_surface(entry)) + .geometry; expect(after.width, closeTo(before.width, .001)); expect( geometryAfter.realX - geometryAfter.effectiveX, @@ -192,7 +196,8 @@ void main() { find.descendant( of: _surface(entry), matching: find.byWidgetPredicate( - (widget) => widget is CustomPaint && + (widget) => + widget is CustomPaint && widget.painter is AdjustedDeadlineExtensionPainter, ), ), @@ -221,12 +226,11 @@ void main() { final surface = tester.widget(_surface(entry)); expect(surface.geometry.realX, surface.geometry.effectiveX); expect(surface.realMarkerColor.a, closeTo(.5, .001)); - expect(tester.getRect(_body(entry)).width, closeTo(48 * 3.75, .001)); - final placement = layoutAgendaTimeline( - [entry], - minimumEventSpan: const Duration(minutes: 1), - ).single; - expect(placement.start, entry.dueAt.subtract(const Duration(days: 4))); + expect(tester.getRect(_body(entry)).width, closeTo(48 * 13.75, .001)); + final placement = layoutAgendaTimeline([ + entry, + ], minimumEventSpan: const Duration(minutes: 1)).single; + expect(placement.start, entry.dueAt.subtract(const Duration(days: 14))); await tester.pumpWidget(const SizedBox()); }); @@ -234,11 +238,9 @@ void main() { tester, ) async { final raw = _deadline(window: const DeadlineCutoffWindow()); - final app = await _showTimeline( - tester, - [raw], - window: const DeadlineCutoffWindow(), - ); + final app = await _showTimeline(tester, [ + raw, + ], window: const DeadlineCutoffWindow()); expect(_surface(raw), findsNothing); final initialRight = tester.getRect(_body(raw)).right; await app.settings.setDeadlineCutoff(_window); @@ -265,11 +267,9 @@ void main() { tester, ) async { final projected = _deadline(); - await _showTimeline( - tester, - [projected], - window: const DeadlineCutoffWindow(), - ); + await _showTimeline(tester, [ + projected, + ], window: const DeadlineCutoffWindow()); expect(_surface(projected), findsNothing); expect(projected.hasAdjustedDeadline, isTrue); await tester.pumpWidget(const SizedBox()); @@ -282,11 +282,9 @@ void main() { at: _date.add(const Duration(hours: 8)), window: const DeadlineCutoffWindow(enabled: true), ); - await _showTimeline( - tester, - [entry], - window: const DeadlineCutoffWindow(enabled: true), - ); + await _showTimeline(tester, [ + entry, + ], window: const DeadlineCutoffWindow(enabled: true)); tester.widget(find.byType(Slider)).onChanged!(24); await tester.pumpAndSettle(); final surface = tester.getRect(_surface(entry)); diff --git a/test/agenda_detail_test.dart b/test/agenda_detail_test.dart index 390fd13..5ea507e 100644 --- a/test/agenda_detail_test.dart +++ b/test/agenda_detail_test.dart @@ -25,22 +25,19 @@ void main() { bool detailed = false, bool allDay = false, int tailDays = 4, - }) => - AgendaEntry( - id: 'assignment', - title: detailed ? '第三章作业:线性表与链表的实现、测试和复杂度分析' : '1', - kind: AgendaKind.deadline, - startsAt: DateTime.utc(2020, 10, 27, 1, 11), - allDay: allDay, - tailDays: tailDays, - course: detailed ? '2026–2027 秋季学期 · 数据结构中文班' : '', - source: detailed ? DeadlineSource.lexue : null, - url: detailed ? url : null, - location: detailed ? '文萃楼F704,计算机实验室' : '', - notes: detailed - ? List.filled(20, '提交源码和实验报告,说明边界条件与测试结果。').join('\n') - : '', - ); + }) => AgendaEntry( + id: 'assignment', + title: detailed ? '第三章作业:线性表与链表的实现、测试和复杂度分析' : '1', + kind: AgendaKind.deadline, + startsAt: DateTime.utc(2020, 10, 27, 1, 11), + allDay: allDay, + tailDays: tailDays, + course: detailed ? '2026–2027 秋季学期 · 数据结构中文班' : '', + source: detailed ? DeadlineSource.lexue : null, + url: detailed ? url : null, + location: detailed ? '文萃楼F704,计算机实验室' : '', + notes: detailed ? List.filled(20, '提交源码和实验报告,说明边界条件与测试结果。').join('\n') : '', + ); Future open( WidgetTester tester, @@ -225,65 +222,123 @@ void main() { expect(find.byType(SheetLayout), findsOneWidget); }); - testWidgets('Edit preserves the entry and legacy width without a per-item slider', ( + testWidgets( + 'Edit restores the per-item tail slider and preserves its value', + (tester) async { + final controller = await open(tester, deadline(tailDays: 14)); + await tester.tap(find.widgetWithText(TextButton, '编辑')); + await tester.pumpAndSettle(); + expect(find.text('编辑DDL'), findsOneWidget); + final name = find.byWidgetPredicate( + (widget) => widget is TextField && widget.decoration?.labelText == '名称', + ); + expect(tester.widget(name).controller!.text, '1'); + expect(find.byKey(const ValueKey('agenda-start-date')), findsOneWidget); + expect(find.byKey(const ValueKey('agenda-start-time')), findsOneWidget); + expect(find.text('备注(选填)'), findsNothing); + expect(find.byType(OutlinedButton), findsNothing); + expect(find.byType(Slider), findsNothing); + expect(find.text('拖尾长度(天)'), findsNothing); + expect(controller.agenda.entries.single.tailDays, 14); + await tester.tap(find.byKey(const ValueKey('agenda-more-options'))); + await tester.pumpAndSettle(); + expect(find.text('备注(选填)'), findsOneWidget); + final tailSlider = find.byKey(const ValueKey('agenda-tail-days-slider')); + expect(tester.widget(tailSlider).value, 14); + tester.widget(tailSlider).onChanged!(9); + await tester.pumpAndSettle(); + await tester.enterText(name, '第二章习题'); + final colorField = find.byKey(const ValueKey('agenda-color-field')); + await tester.ensureVisible(colorField); + await tester.tap(colorField); + await tester.pumpAndSettle(); + expect(find.byType(ColorIndicator), findsWidgets); + expect(presetColor(AgendaColor.blue), findsOneWidget); + final coral = presetColor(AgendaColor.coral); + await tester.tap(coral); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '应用')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '保存')); + await tester.pumpAndSettle(); + expect(controller.agenda.entries.single.id, 'assignment'); + expect(controller.agenda.entries.single.title, '第二章习题'); + expect(controller.agenda.entries.single.color, AgendaColor.coral); + expect(controller.agenda.entries.single.tailDays, 9); + expect(find.byType(SheetLayout), findsNothing); + unawaited( + showAgendaEntry( + tester.element(find.text('打开详情')), + controller.agenda, + controller.agenda.entries.single, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(TextButton, '编辑')); + await tester.pumpAndSettle(); + expect(find.byType(Slider), findsNothing); + expect(controller.agenda.entries.single.tailDays, 9); + await tester.tap(find.byKey(const ValueKey('agenda-more-options'))); + await tester.pumpAndSettle(); + expect(tester.widget(tailSlider).value, 9); + expect( + tester + .widget(find.byKey(const ValueKey('agenda-color-value'))) + .data, + '珊瑚色', + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('An individual tail can return to following the global default', ( tester, ) async { final controller = await open(tester, deadline(tailDays: 14)); await tester.tap(find.widgetWithText(TextButton, '编辑')); await tester.pumpAndSettle(); - expect(find.text('编辑DDL'), findsOneWidget); - final name = find.byWidgetPredicate( - (widget) => widget is TextField && widget.decoration?.labelText == '名称', - ); - expect(tester.widget(name).controller!.text, '1'); - expect(find.byKey(const ValueKey('agenda-start-date')), findsOneWidget); - expect(find.byKey(const ValueKey('agenda-start-time')), findsOneWidget); - expect(find.text('备注(选填)'), findsNothing); - expect(find.byType(OutlinedButton), findsNothing); - expect(find.byType(Slider), findsNothing); - expect(find.text('拖尾长度(天)'), findsNothing); - expect(controller.agenda.entries.single.tailDays, 14); await tester.tap(find.byKey(const ValueKey('agenda-more-options'))); await tester.pumpAndSettle(); - expect(find.text('备注(选填)'), findsOneWidget); - expect(find.byType(Slider), findsNothing); - await tester.enterText(name, '第二章习题'); - final colorField = find.byKey(const ValueKey('agenda-color-field')); - await tester.ensureVisible(colorField); - await tester.tap(colorField); - await tester.pumpAndSettle(); - expect(find.byType(ColorIndicator), findsWidgets); - expect(presetColor(AgendaColor.blue), findsOneWidget); - final coral = presetColor(AgendaColor.coral); - await tester.tap(coral); - await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(FilledButton, '应用')); + final useDefault = find.byKey(const ValueKey('agenda-tail-days-default')); + expect(tester.widget(useDefault).value, isFalse); + expect( + find.byKey(const ValueKey('agenda-tail-days-slider')), + findsOneWidget, + ); + await tester.ensureVisible(useDefault); + await tester.tap(useDefault); await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('agenda-tail-days-slider')), findsNothing); await tester.tap(find.widgetWithText(FilledButton, '保存')); await tester.pumpAndSettle(); - expect(controller.agenda.entries.single.id, 'assignment'); - expect(controller.agenda.entries.single.title, '第二章习题'); - expect(controller.agenda.entries.single.color, AgendaColor.coral); - expect(controller.agenda.entries.single.tailDays, 14); - expect(find.byType(SheetLayout), findsNothing); + final saved = controller.agenda.entries.single; + expect(saved.tailDays, isNull); + expect(saved.effectiveLayoutDays(8), 8); + expect(AgendaEntry.fromJson(saved.toJson()).tailDays, isNull); + unawaited( showAgendaEntry( tester.element(find.text('打开详情')), controller.agenda, - controller.agenda.entries.single, + saved, ), ); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(TextButton, '编辑')); await tester.pumpAndSettle(); - expect(find.byType(Slider), findsNothing); - expect(controller.agenda.entries.single.tailDays, 14); - expect( - tester - .widget(find.byKey(const ValueKey('agenda-color-value'))) - .data, - '珊瑚色', - ); + await tester.tap(find.byKey(const ValueKey('agenda-more-options'))); + await tester.pumpAndSettle(); + expect(tester.widget(useDefault).value, isTrue); + await tester.ensureVisible(useDefault); + await tester.tap(useDefault); + await tester.pumpAndSettle(); + final slider = find.byKey(const ValueKey('agenda-tail-days-slider')); + tester.widget(slider).onChanged!(4); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '保存')); + await tester.pumpAndSettle(); + expect(controller.agenda.entries.single.tailDays, 4); + expect(controller.agenda.entries.single.effectiveLayoutDays(8), 4); expect(tester.takeException(), isNull); }); diff --git a/test/agenda_layout_days_test.dart b/test/agenda_layout_days_test.dart index a4f2e01..441a373 100644 --- a/test/agenda_layout_days_test.dart +++ b/test/agenda_layout_days_test.dart @@ -47,16 +47,18 @@ class _DelayedWritePreferences extends MemoryPreferences { AppSettingsController _settings(MemoryPreferences preferences) => AppSettingsController(preferences, coursePaletteLoader: () async => {}); -AgendaEntry _entry(String id, { +AgendaEntry _entry( + String id, { bool startOnly = false, DeadlineCutoffWindow cutoff = const DeadlineCutoffWindow(), + int? tailDays, }) => AgendaEntry( id: id, title: id, kind: AgendaKind.deadline, startsAt: _date, startOnly: startOnly, - tailDays: id == 'plain' ? 1 : 30, + tailDays: tailDays, deadlineCutoffWindow: cutoff, ); @@ -72,21 +74,24 @@ void main() { expect(preferences.value(_key), isNull); }); - test('Layout days restore, publish immediately and survive restart', () async { - final preferences = MemoryPreferences({_key: '7'}); - final first = _settings(preferences); - addTearDown(first.dispose); - await first.initialize(); - expect(first.agendaLayoutDays.value, 7); - final writing = first.setAgendaLayoutDays(12); - expect(first.agendaLayoutDays.value, 12); - await writing; - expect(preferences.value(_key), '12'); - final restarted = _settings(preferences); - addTearDown(restarted.dispose); - await restarted.initialize(); - expect(restarted.agendaLayoutDays.value, 12); - }); + test( + 'Layout days restore, publish immediately and survive restart', + () async { + final preferences = MemoryPreferences({_key: '7'}); + final first = _settings(preferences); + addTearDown(first.dispose); + await first.initialize(); + expect(first.agendaLayoutDays.value, 7); + final writing = first.setAgendaLayoutDays(12); + expect(first.agendaLayoutDays.value, 12); + await writing; + expect(preferences.value(_key), '12'); + final restarted = _settings(preferences); + addTearDown(restarted.dispose); + await restarted.initialize(); + expect(restarted.agendaLayoutDays.value, 12); + }, + ); for (final invalid in ['', '0', '31', '-2', '2.5', 'unknown']) { test('Invalid saved width falls back to four: $invalid', () async { @@ -146,8 +151,40 @@ void main() { expect(preferences.value(_key), isNull); }); + test( + 'Sync preserves a saved per-item tail on an imported deadline', + () async { + final saved = AgendaEntry( + id: 'imported', + title: 'original', + kind: AgendaKind.deadline, + startsAt: _date, + local: false, + tailDays: 14, + ); + final app = TestAppController(entries: [saved]); + await app.initialize(); + expect( + await app.agenda.mergeDeadlines([ + AgendaEntry( + id: saved.id, + title: 'updated', + kind: saved.kind, + startsAt: _date, + local: false, + ), + ]), + isTrue, + ); + final updated = app.agenda.entries.single; + expect(updated.title, 'updated'); + expect(updated.tailDays, 14); + expect(updated.effectiveLayoutDays(8), 14); + }, + ); + for (final days in [1, 4, 30]) { - test('All DDL reservations use the global $days days, not legacy values', () { + test('DDL reservations without overrides follow the global $days days', () { const cutoff = DeadlineCutoffWindow( enabled: true, startMinute: 10 * 60, @@ -173,118 +210,190 @@ void main() { expect(placement.end, entry.realDueAt); } } - expect(entries.map((e) => e.tailDays), [1, 30, 30]); + expect(entries.map((e) => e.tailDays), [null, null, null]); expect(entries.every((e) => e.startsAt == _date), isTrue); - expect(entries.first.toJson()['tailDays'], 1); - expect(entries.last.toJson()['tailDays'], 30); + expect(entries.every((e) => e.toJson()['tailDays'] == null), isTrue); }); } - testWidgets('One live width controls plain, open and adjusted DDL bodies', ( - tester, - ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - final entries = [_entry('plain'), _entry('open', startOnly: true), _entry('adjusted')]; - final app = TestAppController(entries: entries); - await app.initialize(); - // Plain and adjusted use different clock times so only the latter matches. - final adjusted = AgendaEntry( - id: 'adjusted', title: 'adjusted', kind: AgendaKind.deadline, - startsAt: _date.add(const Duration(hours: 2)), tailDays: 30, + test('Per-item tails override the global width for every DDL type', () { + const cutoff = DeadlineCutoffWindow( + enabled: true, + startMinute: 10 * 60, + endMinute: 18 * 60, ); - final shown = [entries[0], entries[1], adjusted]; - await app.settings.setDeadlineCutoff(const DeadlineCutoffWindow( - enabled: true, startMinute: 13 * 60, endMinute: 15 * 60, - )); - await tester.pumpWidget(MaterialApp( - theme: bitreeTheme(Brightness.light), - home: Scaffold(body: AgendaTimeline( - controller: app, entries: shown, now: _date, - )), - )); - await tester.pumpAndSettle(); - final state = tester.state(find.byType(AgendaTimeline)); + final entries = [ + _entry('plain', tailDays: 1), + _entry('open', startOnly: true, tailDays: 30), + _entry('adjusted', cutoff: cutoff, tailDays: 14), + ]; for (final days in [4, 8]) { - await app.settings.setAgendaLayoutDays(days); - await tester.pumpAndSettle(); - for (final entry in shown) { - final bar = find.byKey(ValueKey(('agenda-timeline-bar', entry.id))); - expect(bar, findsOneWidget); - expect(tester.getSize(bar).width, closeTo(48 * (days - .25), .001)); + final placements = layoutAgendaTimeline( + entries, + minimumEventSpan: const Duration(minutes: 1), + layoutDays: days, + ); + for (final p in placements) { + final entry = p.entry; + final span = Duration(days: entry.tailDays!); + final cardSpan = Duration( + microseconds: (Duration.microsecondsPerDay * (entry.tailDays! - .25)) + .round(), + ); + expect( + p.start, + entry.startOnly ? entry.startsAt : entry.dueAt.subtract(span), + ); + expect( + p.end, + entry.startOnly ? entry.startsAt.add(span) : entry.realDueAt, + ); + expect( + p.paintStart, + entry.startOnly ? entry.startsAt : entry.dueAt.subtract(cardSpan), + ); + expect( + p.paintEnd, + entry.startOnly ? entry.startsAt.add(cardSpan) : entry.realDueAt, + ); } - expect(tester.state(find.byType(AgendaTimeline)), same(state)); } - tester.widget(find.byType(Slider)).onChanged!(24); - await tester.pumpAndSettle(); - for (final entry in shown) { - final bar = find.byKey(ValueKey(('agenda-timeline-bar', entry.id))); - expect(tester.getSize(bar).width, closeTo(48 * (8 - .25), .001)); - } - expect(entries.map((e) => e.tailDays), [1, 30, 30]); - expect(adjusted.tailDays, 30); - expect(adjusted.startsAt, _date.add(const Duration(hours: 2))); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox()); }); - for (final locale in [const Locale('zh'), const Locale('en')]) { - testWidgets('Width settings work on a narrow large-text screen in $locale', ( - tester, - ) async { - tester.view.physicalSize = const Size(320, 640); + testWidgets( + 'Live global changes respect plain, open and adjusted DDL overrides', + (tester) async { + tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final preferences = MemoryPreferences(); - final settings = _settings(preferences); - addTearDown(settings.dispose); - await settings.initialize(); - await tester.pumpWidget(MaterialApp( - locale: locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: bitreeTheme(Brightness.light), - builder: (context, child) => MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(1.8)), - child: child!, + final entries = [ + _entry('plain', tailDays: 1), + _entry('open', startOnly: true), + _entry('custom-open', startOnly: true, tailDays: 6), + _entry('inherited'), + ]; + final app = TestAppController(entries: entries); + await app.initialize(); + // Plain and adjusted use different clock times so only the latter matches. + final adjusted = AgendaEntry( + id: 'adjusted', + title: 'adjusted', + kind: AgendaKind.deadline, + startsAt: _date.add(const Duration(hours: 2)), + tailDays: 2, + ); + final shown = [...entries, adjusted]; + await app.settings.setDeadlineCutoff( + const DeadlineCutoffWindow( + enabled: true, + startMinute: 13 * 60, + endMinute: 15 * 60, ), - home: AgendaLayoutDaysScreen(settings: settings), - )); - await tester.pumpAndSettle(); - final field = find.byKey(const ValueKey('agenda-layout-days-input')); - await tester.scrollUntilVisible( - field, 200, scrollable: find.byType(Scrollable).first, ); - await tester.pumpAndSettle(); - await tester.enterText(field, '7'); - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pumpAndSettle(); - expect(settings.agendaLayoutDays.value, 7); - expect(preferences.value(_key), '7'); - await tester.enterText(field, '31'); - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pumpAndSettle(); - expect(settings.agendaLayoutDays.value, 7); - final context = tester.element(find.byType(AgendaLayoutDaysScreen)); - expect(find.text(context.l10n.agendaLayoutDaysRange), findsWidgets); - await settings.setAgendaLayoutDays(12); - await tester.pumpAndSettle(); - final textField = tester.widget(field); - expect(textField.controller!.text, '12'); - final slider = find.byKey(const ValueKey('agenda-layout-days-slider')); - await tester.scrollUntilVisible( - slider, 200, scrollable: find.byType(Scrollable).first, + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: AgendaTimeline(controller: app, entries: shown, now: _date), + ), + ), ); await tester.pumpAndSettle(); - tester.widget(slider).onChanged!(30); + final state = tester.state(find.byType(AgendaTimeline)); + for (final days in [4, 8]) { + await app.settings.setAgendaLayoutDays(days); + await tester.pumpAndSettle(); + for (final entry in shown) { + final bar = find.byKey(ValueKey(('agenda-timeline-bar', entry.id))); + expect(bar, findsOneWidget); + expect( + tester.getSize(bar).width, + closeTo(48 * (entry.effectiveLayoutDays(days) - .25), .001), + ); + } + expect(tester.state(find.byType(AgendaTimeline)), same(state)); + } + tester.widget(find.byType(Slider)).onChanged!(24); await tester.pumpAndSettle(); - expect(settings.agendaLayoutDays.value, 30); - expect(preferences.value(_key), '30'); + for (final entry in shown) { + final bar = find.byKey(ValueKey(('agenda-timeline-bar', entry.id))); + expect( + tester.getSize(bar).width, + closeTo(48 * (entry.effectiveLayoutDays(8) - .25), .001), + ); + } + expect(entries.map((e) => e.tailDays), [1, null, 6, null]); + expect(adjusted.tailDays, 2); + expect(adjusted.startsAt, _date.add(const Duration(hours: 2))); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox()); - }); + }, + ); + + for (final locale in [const Locale('zh'), const Locale('en')]) { + testWidgets( + 'Width settings work on a narrow large-text screen in $locale', + (tester) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final preferences = MemoryPreferences(); + final settings = _settings(preferences); + addTearDown(settings.dispose); + await settings.initialize(); + await tester.pumpWidget( + MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: bitreeTheme(Brightness.light), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(1.8)), + child: child!, + ), + home: AgendaLayoutDaysScreen(settings: settings), + ), + ); + await tester.pumpAndSettle(); + final field = find.byKey(const ValueKey('agenda-layout-days-input')); + await tester.scrollUntilVisible( + field, + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + await tester.enterText(field, '7'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(settings.agendaLayoutDays.value, 7); + expect(preferences.value(_key), '7'); + await tester.enterText(field, '31'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(settings.agendaLayoutDays.value, 7); + final context = tester.element(find.byType(AgendaLayoutDaysScreen)); + expect(find.text(context.l10n.agendaLayoutDaysRange), findsWidgets); + await settings.setAgendaLayoutDays(12); + await tester.pumpAndSettle(); + final textField = tester.widget(field); + expect(textField.controller!.text, '12'); + final slider = find.byKey(const ValueKey('agenda-layout-days-slider')); + await tester.scrollUntilVisible( + slider, + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + tester.widget(slider).onChanged!(30); + await tester.pumpAndSettle(); + expect(settings.agendaLayoutDays.value, 30); + expect(preferences.value(_key), '30'); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); } } diff --git a/test/agenda_timeline_projection_test.dart b/test/agenda_timeline_projection_test.dart index 363d5f4..bc73b35 100644 --- a/test/agenda_timeline_projection_test.dart +++ b/test/agenda_timeline_projection_test.dart @@ -5,79 +5,121 @@ import 'package:flutter_test/flutter_test.dart'; void main() { final day = DateTime.utc(2026, 11, 17); const window = DeadlineCutoffWindow( - enabled: true, startMinute: 10 * 60, endMinute: 18 * 60, + enabled: true, + startMinute: 10 * 60, + endMinute: 18 * 60, ); - test('A retained layout preserves complete feature reservations at every zoom', () { - final entries = [ - AgendaEntry( - id: 'plain', title: '普通DDL', kind: AgendaKind.deadline, - startsAt: day.add(const Duration(hours: 8)), tailDays: 1, - ), - AgendaEntry( - id: 'adjusted', title: '伪截止', kind: AgendaKind.deadline, - startsAt: day.add(const Duration(hours: 14)), - deadlineCutoffWindow: window, tailDays: 30, - ), - AgendaEntry( - id: 'open', title: '仅开始', kind: AgendaKind.deadline, - startsAt: day.add(const Duration(hours: 12)), startOnly: true, - ), - AgendaEntry( - id: 'event', title: '普通日程', kind: AgendaKind.event, - startsAt: day.add(const Duration(hours: 11)), - endsAt: day.add(const Duration(hours: 13)), - ), - AgendaEntry( - id: 'exam', title: '考试', kind: AgendaKind.exam, - startsAt: day.add(const Duration(hours: 12)), - endsAt: day.add(const Duration(hours: 15)), - ), - ]; - const days = 6; - const cardSpan = Duration(hours: 138); // (6 - .25) baseline days. - final layout = AgendaTimelineLayout( - entries, - minimumEventSpan: const Duration(hours: 1), - deadlineCardSpan: cardSpan, - layoutDays: days, - ); - final initial = layout.atZoom(1); - final lanes = {for (final p in initial) p.entry.id: p.lane}; - final bounds = { - for (final p in initial) - p.entry.id: (start: p.start, end: p.end, layoutEnd: p.layoutEnd), - }; - for (final zoom in [1.0, 2.0, 8.0, 24.0, 4.0, 1.0]) { - for (final p in layout.atZoom(zoom)) { - final entry = p.entry; - expect(p.lane, lanes[entry.id]); - expect((start: p.start, end: p.end, layoutEnd: p.layoutEnd), bounds[entry.id]); - final fixedSpan = Duration( - microseconds: (cardSpan.inMicroseconds / zoom).ceil(), - ); - final anchor = entry.isDeadline ? entry.dueAt : entry.startsAt; - expect(p.start, entry.startOnly - ? entry.startsAt : anchor.subtract(const Duration(days: days))); - expect(p.end, entry.startOnly - ? entry.startsAt.add(const Duration(days: days)) - : entry.isDeadline ? entry.realDueAt : entry.endsAt); - expect(p.paintStart, entry.startOnly - ? entry.startsAt : anchor.subtract(fixedSpan)); - expect(p.paintEnd, entry.startOnly - ? entry.startsAt.add(fixedSpan) : p.end); + test( + 'A retained layout preserves complete feature reservations at every zoom', + () { + final entries = [ + AgendaEntry( + id: 'plain', + title: '普通DDL', + kind: AgendaKind.deadline, + startsAt: day.add(const Duration(hours: 8)), + tailDays: 1, + ), + AgendaEntry( + id: 'adjusted', + title: '伪截止', + kind: AgendaKind.deadline, + startsAt: day.add(const Duration(hours: 14)), + deadlineCutoffWindow: window, + tailDays: 30, + ), + AgendaEntry( + id: 'open', + title: '仅开始', + kind: AgendaKind.deadline, + startsAt: day.add(const Duration(hours: 12)), + startOnly: true, + ), + AgendaEntry( + id: 'event', + title: '普通日程', + kind: AgendaKind.event, + startsAt: day.add(const Duration(hours: 11)), + endsAt: day.add(const Duration(hours: 13)), + ), + AgendaEntry( + id: 'exam', + title: '考试', + kind: AgendaKind.exam, + startsAt: day.add(const Duration(hours: 12)), + endsAt: day.add(const Duration(hours: 15)), + ), + ]; + const days = 6; + const cardSpan = Duration(hours: 138); // (6 - .25) baseline days. + final layout = AgendaTimelineLayout( + entries, + minimumEventSpan: const Duration(hours: 1), + deadlineCardSpan: cardSpan, + layoutDays: days, + ); + final initial = layout.atZoom(1); + final lanes = {for (final p in initial) p.entry.id: p.lane}; + final bounds = { + for (final p in initial) + p.entry.id: (start: p.start, end: p.end, layoutEnd: p.layoutEnd), + }; + for (final zoom in [1.0, 2.0, 8.0, 24.0, 4.0, 1.0]) { + for (final p in layout.atZoom(zoom)) { + final entry = p.entry; + expect(p.lane, lanes[entry.id]); + expect(( + start: p.start, + end: p.end, + layoutEnd: p.layoutEnd, + ), bounds[entry.id]); + final effectiveDays = entry.effectiveLayoutDays(days); + final fixedSpan = Duration( + microseconds: + (Duration.microsecondsPerDay * (effectiveDays - .25) / zoom) + .ceil(), + ); + final anchor = entry.isDeadline ? entry.dueAt : entry.startsAt; + expect( + p.start, + entry.startOnly + ? entry.startsAt + : anchor.subtract(Duration(days: effectiveDays)), + ); + expect( + p.end, + entry.startOnly + ? entry.startsAt.add(Duration(days: effectiveDays)) + : entry.isDeadline + ? entry.realDueAt + : entry.endsAt, + ); + expect( + p.paintStart, + entry.startOnly ? entry.startsAt : anchor.subtract(fixedSpan), + ); + expect( + p.paintEnd, + entry.startOnly ? entry.startsAt.add(fixedSpan) : p.end, + ); + } } - } - }); + }, + ); test('An extension or information reservation cannot yield its lane to a neighbour', () { final adjusted = AgendaEntry( - id: 'adjusted', title: '伪提前', kind: AgendaKind.deadline, + id: 'adjusted', + title: '伪提前', + kind: AgendaKind.deadline, startsAt: day.add(const Duration(hours: 14)), deadlineCutoffWindow: window, ); final event = AgendaEntry( - id: 'event', title: '延伸段内日程', kind: AgendaKind.event, + id: 'event', + title: '延伸段内日程', + kind: AgendaKind.event, startsAt: day.add(const Duration(hours: 11)), endsAt: day.add(const Duration(hours: 12)), ); diff --git a/test/deadline_cutoff_settings_test.dart b/test/deadline_cutoff_settings_test.dart index 9adc88d..8d58318 100644 --- a/test/deadline_cutoff_settings_test.dart +++ b/test/deadline_cutoff_settings_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math' as math; import 'package:bitree/agenda/deadline_cutoff.dart'; import 'package:bitree/app/settings_controller.dart'; @@ -98,7 +99,8 @@ Future _showCutoff( Future _expectSummary( WidgetTester tester, - String Function(AppLocalizations l10n, MaterialLocalizations material) expected, + String Function(AppLocalizations l10n, MaterialLocalizations material) + expected, ) async { final summary = find.byKey(const ValueKey('deadline-cutoff-summary')); await tester.ensureVisible(summary); @@ -117,24 +119,32 @@ String _formattedTime(MaterialLocalizations material, int hour, int minute) => ); Future _selectEndpoint(WidgetTester tester, {required bool start}) async { - final context = tester.element(find.byType(DeadlineCutoffScreen)); - final l10n = AppLocalizations.of(context); - final target = find.descendant( - of: find.byKey(const ValueKey('deadline-cutoff-endpoint')), - matching: find.text(start ? l10n.deadlineCutoffStart : l10n.deadlineCutoffEnd), + final target = find.byKey( + ValueKey(start ? 'deadline-cutoff-start' : 'deadline-cutoff-end'), ); await tester.ensureVisible(target); await tester.tap(target); await tester.pumpAndSettle(); } -Future _nativeInputMode(WidgetTester tester) async { - final context = tester.element(find.byType(TimePickerDialog)); - final button = find.byTooltip( - MaterialLocalizations.of(context).inputTimeModeButtonLabel, +Future _chooseTime(WidgetTester tester, int hour, int minute) async { + final dial = find.descendant( + of: find.byType(TimePickerDialog), + matching: find.byWidgetPredicate( + (widget) => widget is GestureDetector && widget.onPanUpdate != null, + ), + ); + final rect = tester.getRect(dial); + Offset position(int value, int divisions, double radius) { + final angle = 2 * math.pi * value / divisions - math.pi / 2; + return rect.center + Offset(math.cos(angle), math.sin(angle)) * radius; + } + + await tester.tapAt( + position(hour % 12, 12, rect.width * (hour >= 12 ? .27 : .4)), ); - await tester.ensureVisible(button); - await tester.tap(button); + await tester.pumpAndSettle(); + await tester.tapAt(position(minute, 60, rect.width * .4)); await tester.pumpAndSettle(); } @@ -155,72 +165,82 @@ Future _pickerAction(WidgetTester tester, {required bool confirm}) async { void main() { TestWidgetsFlutterBinding.ensureInitialized(); - test('Cutoff defaults to disabled and retains the overnight preset', () async { - final preferences = MemoryPreferences(); - final settings = _settings(preferences); - addTearDown(settings.dispose); - expect(settings.deadlineCutoff.value, const DeadlineCutoffWindow()); - await settings.initialize(); - expect(settings.deadlineCutoff.value.enabled, isFalse); - expect(settings.deadlineCutoff.value.startMinute, 22 * 60); - expect(settings.deadlineCutoff.value.endMinute, 8 * 60); - expect(preferences.value(_key), isNull); - }); + test( + 'Cutoff defaults to disabled and retains the overnight preset', + () async { + final preferences = MemoryPreferences(); + final settings = _settings(preferences); + addTearDown(settings.dispose); + expect(settings.deadlineCutoff.value, const DeadlineCutoffWindow()); + await settings.initialize(); + expect(settings.deadlineCutoff.value.enabled, isFalse); + expect(settings.deadlineCutoff.value.startMinute, 22 * 60); + expect(settings.deadlineCutoff.value.endMinute, 8 * 60); + expect(preferences.value(_key), isNull); + }, + ); - test('A saved cutoff is restored and a disabled choice survives restart', () async { - const saved = DeadlineCutoffWindow( - enabled: true, - startMinute: 21 * 60 + 30, - endMinute: 7 * 60 + 15, - ); - final preferences = MemoryPreferences({_key: jsonEncode(saved.toJson())}); - final first = _settings(preferences); - addTearDown(first.dispose); - await first.initialize(); - expect(first.deadlineCutoff.value, saved); - var settingNotifications = 0; - var valueNotifications = 0; - first.addListener(() => settingNotifications++); - first.deadlineCutoff.addListener(() => valueNotifications++); - - final disabled = saved.copyWith(enabled: false); - final writing = first.setDeadlineCutoff(disabled); - expect(first.deadlineCutoff.value, disabled); - expect(settingNotifications, 1); - expect(valueNotifications, 1); - await writing; - expect(jsonDecode(preferences.value(_key)!), disabled.toJson()); - - final restarted = _settings(preferences); - addTearDown(restarted.dispose); - await restarted.initialize(); - expect(restarted.deadlineCutoff.value, disabled); - expect(restarted.deadlineCutoff.value.startMinute, saved.startMinute); - expect(restarted.deadlineCutoff.value.endMinute, saved.endMinute); - }); + test( + 'A saved cutoff is restored and a disabled choice survives restart', + () async { + const saved = DeadlineCutoffWindow( + enabled: true, + startMinute: 21 * 60 + 30, + endMinute: 7 * 60 + 15, + ); + final preferences = MemoryPreferences({_key: jsonEncode(saved.toJson())}); + final first = _settings(preferences); + addTearDown(first.dispose); + await first.initialize(); + expect(first.deadlineCutoff.value, saved); + var settingNotifications = 0; + var valueNotifications = 0; + first.addListener(() => settingNotifications++); + first.deadlineCutoff.addListener(() => valueNotifications++); + + final disabled = saved.copyWith(enabled: false); + final writing = first.setDeadlineCutoff(disabled); + expect(first.deadlineCutoff.value, disabled); + expect(settingNotifications, 1); + expect(valueNotifications, 1); + await writing; + expect(jsonDecode(preferences.value(_key)!), disabled.toJson()); + + final restarted = _settings(preferences); + addTearDown(restarted.dispose); + await restarted.initialize(); + expect(restarted.deadlineCutoff.value, disabled); + expect(restarted.deadlineCutoff.value.startMinute, saved.startMinute); + expect(restarted.deadlineCutoff.value.endMinute, saved.endMinute); + }, + ); - test('Rapid cutoff changes save in order with the newest value last', () async { - final preferences = _DelayedWritePreferences(); - final settings = _settings(preferences); - addTearDown(settings.dispose); - await settings.initialize(); - const enabled = DeadlineCutoffWindow(enabled: true); - final first = settings.setDeadlineCutoff(enabled); - await preferences.started.future; - final changed = enabled.copyWith(startMinute: 20 * 60); - final second = settings.setDeadlineCutoff(changed); - final disabled = changed.copyWith(enabled: false); - final last = settings.setDeadlineCutoff(disabled); - expect(settings.deadlineCutoff.value, disabled); - expect(preferences.writes, hasLength(1)); - preferences.release.complete(); - await Future.wait([first, second, last]); - expect( - preferences.writes.map(jsonDecode).toList(), - [enabled.toJson(), changed.toJson(), disabled.toJson()], - ); - expect(jsonDecode(preferences.value(_key)!), disabled.toJson()); - }); + test( + 'Rapid cutoff changes save in order with the newest value last', + () async { + final preferences = _DelayedWritePreferences(); + final settings = _settings(preferences); + addTearDown(settings.dispose); + await settings.initialize(); + const enabled = DeadlineCutoffWindow(enabled: true); + final first = settings.setDeadlineCutoff(enabled); + await preferences.started.future; + final changed = enabled.copyWith(startMinute: 20 * 60); + final second = settings.setDeadlineCutoff(changed); + final disabled = changed.copyWith(enabled: false); + final last = settings.setDeadlineCutoff(disabled); + expect(settings.deadlineCutoff.value, disabled); + expect(preferences.writes, hasLength(1)); + preferences.release.complete(); + await Future.wait([first, second, last]); + expect(preferences.writes.map(jsonDecode).toList(), [ + enabled.toJson(), + changed.toJson(), + disabled.toJson(), + ]); + expect(jsonDecode(preferences.value(_key)!), disabled.toJson()); + }, + ); test('A late initialize read cannot replace a newer cutoff choice', () async { final preferences = _DelayedReadPreferences(); @@ -230,7 +250,9 @@ void main() { await preferences.started.future; const chosen = DeadlineCutoffWindow(enabled: true, startMinute: 600); await settings.setDeadlineCutoff(chosen); - preferences.result.complete(jsonEncode(const DeadlineCutoffWindow().toJson())); + preferences.result.complete( + jsonEncode(const DeadlineCutoffWindow().toJson()), + ); await loading; expect(settings.deadlineCutoff.value, chosen); expect(jsonDecode(preferences.value(_key)!), chosen.toJson()); @@ -290,109 +312,125 @@ void main() { }); for (final locale in [const Locale('zh'), const Locale('en')]) { - testWidgets('Cutoff settings explain every interval mode in ${locale.languageCode}', ( - tester, - ) async { + testWidgets( + 'Cutoff settings explain every interval mode in ${locale.languageCode}', + (tester) async { + final preferences = MemoryPreferences(); + final settings = _settings(preferences); + addTearDown(settings.dispose); + await settings.initialize(); + await _showCutoff( + tester, + settings, + locale: locale, + size: const Size(320, 640), + scale: 1.8, + ); + final context = tester.element(find.byType(DeadlineCutoffScreen)); + final l10n = AppLocalizations.of(context); + expect(find.text(l10n.settingsDeadlineCutoff), findsOneWidget); + expect( + tester + .widget( + find.byKey(const ValueKey('deadline-cutoff-enabled')), + ) + .value, + isFalse, + ); + expect(find.byType(TimePickerDialog), findsNothing); + expect(find.byType(TextField), findsNothing); + expect( + find.byKey(const ValueKey('deadline-cutoff-start')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('deadline-cutoff-end')), + findsOneWidget, + ); + await _expectSummary( + tester, + (l10n, material) => l10n.deadlineCutoffOvernight( + _formattedTime(material, 22, 0), + _formattedTime(material, 8, 0), + ), + ); + final switcher = find.byKey(const ValueKey('deadline-cutoff-enabled')); + await tester.ensureVisible(switcher); + await tester.tap(switcher); + await tester.pumpAndSettle(); + expect(settings.deadlineCutoff.value.enabled, isTrue); + await tester.tap(switcher); + await tester.pumpAndSettle(); + expect(settings.deadlineCutoff.value.enabled, isFalse); + expect(settings.deadlineCutoff.value.startMinute, 22 * 60); + expect(settings.deadlineCutoff.value.endMinute, 8 * 60); + + await settings.setDeadlineCutoff( + const DeadlineCutoffWindow(startMinute: 600, endMinute: 720), + ); + await tester.pumpAndSettle(); + await _expectSummary( + tester, + (l10n, material) => l10n.deadlineCutoffSameDay( + _formattedTime(material, 10, 0), + _formattedTime(material, 12, 0), + ), + ); + await settings.setDeadlineCutoff( + const DeadlineCutoffWindow(startMinute: 600, endMinute: 600), + ); + await tester.pumpAndSettle(); + await _expectSummary( + tester, + (l10n, material) => + l10n.deadlineCutoffInstant(_formattedTime(material, 10, 0)), + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); + } + + testWidgets( + 'Time dials open on tap and cancel either endpoint without saving', + (tester) async { final preferences = MemoryPreferences(); final settings = _settings(preferences); addTearDown(settings.dispose); await settings.initialize(); - await _showCutoff( - tester, - settings, - locale: locale, - size: const Size(320, 640), - scale: 1.8, - ); - final context = tester.element(find.byType(DeadlineCutoffScreen)); - final l10n = AppLocalizations.of(context); - expect(find.text(l10n.settingsDeadlineCutoff), findsOneWidget); - expect( - tester.widget( - find.byKey(const ValueKey('deadline-cutoff-enabled')), - ).value, - isFalse, - ); - expect(find.byType(TimePickerDialog), findsOneWidget); - expect( - tester.widget(find.byType(TimePickerDialog)) - .initialEntryMode, - TimePickerEntryMode.dial, - ); - expect(find.byKey(const ValueKey('deadline-cutoff-start')), findsOneWidget); - expect(find.byKey(const ValueKey('deadline-cutoff-end')), findsOneWidget); - await _expectSummary(tester, (l10n, material) => - l10n.deadlineCutoffOvernight( - _formattedTime(material, 22, 0), - _formattedTime(material, 8, 0), - )); - final switcher = find.byKey(const ValueKey('deadline-cutoff-enabled')); - await tester.ensureVisible(switcher); - await tester.tap(switcher); - await tester.pumpAndSettle(); - expect(settings.deadlineCutoff.value.enabled, isTrue); - await tester.tap(switcher); - await tester.pumpAndSettle(); - expect(settings.deadlineCutoff.value.enabled, isFalse); - expect(settings.deadlineCutoff.value.startMinute, 22 * 60); - expect(settings.deadlineCutoff.value.endMinute, 8 * 60); - - await settings.setDeadlineCutoff( - const DeadlineCutoffWindow(startMinute: 600, endMinute: 720), - ); - await tester.pumpAndSettle(); - await _expectSummary(tester, (l10n, material) => - l10n.deadlineCutoffSameDay( - _formattedTime(material, 10, 0), - _formattedTime(material, 12, 0), - )); - await settings.setDeadlineCutoff( - const DeadlineCutoffWindow(startMinute: 600, endMinute: 600), - ); - await tester.pumpAndSettle(); - await _expectSummary(tester, (l10n, material) => - l10n.deadlineCutoffInstant(_formattedTime(material, 10, 0))); + await _showCutoff(tester, settings); + expect(find.byType(TimePickerDialog), findsNothing); + expect(find.byType(TextField), findsNothing); + for (final start in [true, false]) { + await _selectEndpoint(tester, start: start); + final picker = tester.widget( + find.byType(TimePickerDialog), + ); + expect(picker.initialEntryMode, TimePickerEntryMode.dial); + expect( + find.byTooltip( + MaterialLocalizations.of( + tester.element(find.byType(TimePickerDialog)), + ).inputTimeModeButtonLabel, + ), + findsOneWidget, + ); + expect(picker.initialTime, TimeOfDay(hour: start ? 22 : 8, minute: 0)); + expect(find.byType(TextField), findsNothing); + await _chooseTime(tester, 3, 45); + expect(preferences.value(_key), isNull); + await _pickerAction(tester, confirm: false); + expect(find.byType(DeadlineCutoffScreen), findsOneWidget); + expect(find.byType(TimePickerDialog), findsNothing); + expect(settings.deadlineCutoff.value, const DeadlineCutoffWindow()); + expect(preferences.value(_key), isNull); + } expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox()); - }); - } - - testWidgets('Cancel resets either resident Material picker without saving', ( - tester, - ) async { - final preferences = MemoryPreferences(); - final settings = _settings(preferences); - addTearDown(settings.dispose); - await settings.initialize(); - await _showCutoff(tester, settings); - // The native component exists immediately, before any field/button tap. - expect(find.byType(TimePickerDialog), findsOneWidget); - for (final start in [true, false]) { - await _selectEndpoint(tester, start: start); - await _nativeInputMode(tester); - final inputs = find.descendant( - of: find.byType(TimePickerDialog), - matching: find.byType(TextField), - ); - expect(inputs, findsNWidgets(2)); - await tester.enterText(inputs.first, '03'); - await tester.enterText(inputs.last, '45'); - expect(preferences.value(_key), isNull); - await _pickerAction(tester, confirm: false); - expect(find.byType(DeadlineCutoffScreen), findsOneWidget); - expect(find.byType(TimePickerDialog), findsOneWidget); - expect( - tester.widget(find.byType(TimePickerDialog)).initialTime, - TimeOfDay(hour: start ? 22 : 8, minute: 0), - ); - expect(settings.deadlineCutoff.value, const DeadlineCutoffWindow()); - expect(preferences.value(_key), isNull); - } - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox()); - }); + }, + ); - testWidgets('Resident picker confirms only its endpoint using latest settings', ( + testWidgets('A dial confirms only its endpoint using the latest settings', ( tester, ) async { final preferences = MemoryPreferences(); @@ -400,16 +438,9 @@ void main() { addTearDown(settings.dispose); await settings.initialize(); await _showCutoff(tester, settings); - await _nativeInputMode(tester); + await _selectEndpoint(tester, start: true); + await _chooseTime(tester, 21, 15); final pickerElement = tester.element(find.byType(TimePickerDialog)); - final inputs = find.descendant( - of: find.byType(TimePickerDialog), - matching: find.byType(TextField), - ); - expect(inputs, findsNWidgets(2)); - await tester.enterText(inputs.first, '21'); - await tester.enterText(inputs.last, '15'); - // Enabled/opposite-endpoint updates preserve the native picker's draft. await settings.setDeadlineCutoff( const DeadlineCutoffWindow(enabled: true, endMinute: 600), ); @@ -419,137 +450,102 @@ void main() { expect(settings.deadlineCutoff.value.startMinute, 21 * 60 + 15); expect(settings.deadlineCutoff.value.endMinute, 600); expect(settings.deadlineCutoff.value.enabled, isTrue); - expect(jsonDecode(preferences.value(_key)!), settings.deadlineCutoff.value.toJson()); - expect(find.byType(DeadlineCutoffScreen), findsOneWidget); - expect(find.byType(TimePickerDialog), findsOneWidget); expect( - tester.widget(find.byKey(const ValueKey('deadline-cutoff-start'))) - .controller!.text, - '21:15', + jsonDecode(preferences.value(_key)!), + settings.deadlineCutoff.value.toJson(), ); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox()); - }); - - testWidgets('Resident text fields validate before saving and reset the dial', ( - tester, - ) async { - final preferences = MemoryPreferences(); - final settings = _settings(preferences); - addTearDown(settings.dispose); - await settings.initialize(); - await _showCutoff(tester, settings); - final input = find.byKey(const ValueKey('deadline-cutoff-start')); - await tester.ensureVisible(input); - await tester.enterText(input, '25:10'); - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pumpAndSettle(); - final material = MaterialLocalizations.of(tester.element(input)); - expect(find.text(material.invalidTimeLabel), findsOneWidget); - expect(settings.deadlineCutoff.value.startMinute, 22 * 60); - expect(preferences.value(_key), isNull); + expect(find.byType(DeadlineCutoffScreen), findsOneWidget); + expect(find.byType(TimePickerDialog), findsNothing); + expect(find.text('21:15'), findsOneWidget); - await tester.enterText(input, '21:'); - await tester.pump(); - expect(preferences.value(_key), isNull); - await tester.enterText(input, '21:15'); - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pumpAndSettle(); + await _selectEndpoint(tester, start: false); + await _chooseTime(tester, 3, 45); + await _pickerAction(tester, confirm: true); expect(settings.deadlineCutoff.value.startMinute, 21 * 60 + 15); - expect(settings.deadlineCutoff.value.enabled, isFalse); - expect(settings.deadlineCutoff.value.endMinute, 8 * 60); - expect(find.text(material.invalidTimeLabel), findsNothing); + expect(settings.deadlineCutoff.value.endMinute, 3 * 60 + 45); + expect(settings.deadlineCutoff.value.enabled, isTrue); expect( - tester.widget(find.byType(TimePickerDialog)).initialTime, - const TimeOfDay(hour: 21, minute: 15), + jsonDecode(preferences.value(_key)!), + settings.deadlineCutoff.value.toJson(), ); - expect(find.byType(TimePickerDialog), findsOneWidget); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox()); }); - testWidgets('The same native picker can switch between dial and input modes', ( - tester, - ) async { - final settings = _settings(MemoryPreferences()); - addTearDown(settings.dispose); - await settings.initialize(); - await _showCutoff(tester, settings); - final pickerElement = tester.element(find.byType(TimePickerDialog)); - await _nativeInputMode(tester); - expect(tester.element(find.byType(TimePickerDialog)), same(pickerElement)); - expect( - find.descendant( - of: find.byType(TimePickerDialog), - matching: find.byType(TextField), - ), - findsNWidgets(2), - ); - final context = tester.element(find.byType(TimePickerDialog)); - final dialButton = find.byTooltip( - MaterialLocalizations.of(context).dialModeButtonLabel, - ); - await tester.ensureVisible(dialButton); - await tester.tap(dialButton); - await tester.pumpAndSettle(); - expect(tester.element(find.byType(TimePickerDialog)), same(pickerElement)); - expect( - find.descendant( - of: find.byType(TimePickerDialog), - matching: find.byType(TextField), - ), - findsNothing, + for (final locale in [const Locale('zh'), const Locale('en')]) { + testWidgets( + 'The dial fits a narrow screen with large text in ${locale.languageCode}', + (tester) async { + final settings = _settings(MemoryPreferences()); + addTearDown(settings.dispose); + await settings.initialize(); + await _showCutoff( + tester, + settings, + locale: locale, + size: const Size(320, 640), + scale: 1.8, + ); + await _selectEndpoint(tester, start: false); + expect(find.byType(TimePickerDialog), findsOneWidget); + expect(find.byType(TextField), findsNothing); + await _pickerAction(tester, confirm: false); + expect(find.byType(DeadlineCutoffScreen), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, ); - expect(find.byKey(const ValueKey('deadline-cutoff-start')), findsOneWidget); - expect(find.byKey(const ValueKey('deadline-cutoff-end')), findsOneWidget); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox()); - }); + } - testWidgets('Native picker actions never pop the surrounding settings route', ( - tester, - ) async { - final settings = _settings(MemoryPreferences()); - addTearDown(settings.dispose); - await settings.initialize(); - await tester.pumpWidget(MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - locale: const Locale('zh'), - theme: bitreeTheme(Brightness.light), - home: Scaffold( - body: Builder( - builder: (context) => Center( - child: TextButton( - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => DeadlineCutoffScreen(settings: settings), + testWidgets( + 'Native picker actions never pop the surrounding settings route', + (tester) async { + final settings = _settings(MemoryPreferences()); + addTearDown(settings.dispose); + await settings.initialize(); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('zh'), + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DeadlineCutoffScreen(settings: settings), + ), + ), + child: const Text('打开设置'), ), ), - child: const Text('打开设置'), ), ), ), - ), - )); - await tester.tap(find.text('打开设置')); - await tester.pumpAndSettle(); - expect(find.byType(TimePickerDialog), findsOneWidget); - await _pickerAction(tester, confirm: false); - expect(find.byType(DeadlineCutoffScreen), findsOneWidget); - expect(find.byType(TimePickerDialog), findsOneWidget); - await _pickerAction(tester, confirm: true); - expect(find.byType(DeadlineCutoffScreen), findsOneWidget); - expect(find.byType(TimePickerDialog), findsOneWidget); - final back = find.byType(BackButton); - await tester.ensureVisible(back); - await tester.tap(back); - await tester.pumpAndSettle(); - expect(find.byType(DeadlineCutoffScreen), findsNothing); - expect(find.text('打开设置'), findsOneWidget); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox()); - }); + ); + await tester.tap(find.text('打开设置')); + await tester.pumpAndSettle(); + expect(find.byType(TimePickerDialog), findsNothing); + await _selectEndpoint(tester, start: true); + await _pickerAction(tester, confirm: false); + expect(find.byType(DeadlineCutoffScreen), findsOneWidget); + expect(find.byType(TimePickerDialog), findsNothing); + await _selectEndpoint(tester, start: false); + await _pickerAction(tester, confirm: true); + expect(find.byType(DeadlineCutoffScreen), findsOneWidget); + expect(find.byType(TimePickerDialog), findsNothing); + final back = find.byType(BackButton); + await tester.ensureVisible(back); + await tester.tap(back); + await tester.pumpAndSettle(); + expect(find.byType(DeadlineCutoffScreen), findsNothing); + expect(find.text('打开设置'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); testWidgets('Settings expose cutoff without a timetable or school account', ( tester, diff --git a/test/event_info_bar_test.dart b/test/event_info_bar_test.dart index 903e9d0..8977282 100644 --- a/test/event_info_bar_test.dart +++ b/test/event_info_bar_test.dart @@ -2,6 +2,7 @@ import 'package:bitree/agenda/model.dart'; import 'package:bitree/agenda/timeline_layout.dart'; import 'package:bitree/agenda/ui/agenda_timeline.dart'; import 'package:bitree/agenda/ui/event_timeline_entry.dart'; +import 'package:bitree/agenda/ui/timeline_entry.dart'; import 'package:bitree/app/settings_controller.dart'; import 'package:bitree/app/ui/settings_screen.dart'; import 'package:bitree/ui/theme.dart'; @@ -16,10 +17,15 @@ final _date = DateTime.utc(2026, 11, 17); AppSettingsController _settings(MemoryPreferences preferences) => AppSettingsController(preferences, coursePaletteLoader: () async => {}); -AgendaEntry _timed(String id, int startHour, int endHour) => AgendaEntry( +AgendaEntry _timed( + String id, + int startHour, + int endHour, { + AgendaKind kind = AgendaKind.event, +}) => AgendaEntry( id: id, title: id, - kind: AgendaKind.event, + kind: kind, startsAt: _date.add(Duration(hours: startHour)), endsAt: _date.add(Duration(hours: endHour)), ); @@ -27,30 +33,36 @@ AgendaEntry _timed(String id, int startHour, int endHour) => AgendaEntry( void main() { TestWidgetsFlutterBinding.ensureInitialized(); - test('Information bar defaults to visible without writing preferences', () async { - final preferences = MemoryPreferences(); - final settings = _settings(preferences); - addTearDown(settings.dispose); - await settings.initialize(); - expect(settings.agendaEventInfoBar.value, isTrue); - expect(preferences.value(_key), isNull); - }); + test( + 'Information bar defaults to visible without writing preferences', + () async { + final preferences = MemoryPreferences(); + final settings = _settings(preferences); + addTearDown(settings.dispose); + await settings.initialize(); + expect(settings.agendaEventInfoBar.value, isTrue); + expect(preferences.value(_key), isNull); + }, + ); - test('Hidden bar restores, publishes immediately and survives restart', () async { - final preferences = MemoryPreferences({_key: 'false'}); - final first = _settings(preferences); - addTearDown(first.dispose); - await first.initialize(); - expect(first.agendaEventInfoBar.value, isFalse); - final writing = first.setAgendaEventInfoBar(true); - expect(first.agendaEventInfoBar.value, isTrue); - await writing; - expect(preferences.value(_key), 'true'); - final restarted = _settings(preferences); - addTearDown(restarted.dispose); - await restarted.initialize(); - expect(restarted.agendaEventInfoBar.value, isTrue); - }); + test( + 'Hidden bar restores, publishes immediately and survives restart', + () async { + final preferences = MemoryPreferences({_key: 'false'}); + final first = _settings(preferences); + addTearDown(first.dispose); + await first.initialize(); + expect(first.agendaEventInfoBar.value, isFalse); + final writing = first.setAgendaEventInfoBar(true); + expect(first.agendaEventInfoBar.value, isTrue); + await writing; + expect(preferences.value(_key), 'true'); + final restarted = _settings(preferences); + addTearDown(restarted.dispose); + await restarted.initialize(); + expect(restarted.agendaEventInfoBar.value, isTrue); + }, + ); for (final invalid in ['', 'yes', 'TRUE', '1']) { test('Invalid saved visibility falls back to visible: $invalid', () async { @@ -88,131 +100,156 @@ void main() { ); expect( painted.paintStart, - reserve - ? event.startsAt.subtract(fixedCardSpan) - : event.startsAt, + reserve ? event.startsAt.subtract(fixedCardSpan) : event.startsAt, ); expect(painted.end, event.endsAt); expect(painted.paintEnd, event.endsAt); // Deadlines keep their information lead regardless of this setting. - expect(byId['ddl']!.start, deadline.dueAt.subtract(const Duration(days: 4))); + expect( + byId['ddl']!.start, + deadline.dueAt.subtract(const Duration(days: 4)), + ); expect(byId['ddl']!.paintStart, deadline.dueAt.subtract(fixedCardSpan)); } }); - testWidgets('The switch hides event text, releases its lane lead, and restores', ( - tester, - ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - final entries = [ - _timed('morning', 11, 13), - _timed('afternoon', 13, 15), - AgendaEntry( - id: 'ddl', - title: 'ddl', - kind: AgendaKind.deadline, - startsAt: _date.add(const Duration(days: 1, hours: 12)), - ), - ]; - final app = TestAppController(entries: entries); - await app.initialize(); - await tester.pumpWidget(MaterialApp( - theme: bitreeTheme(Brightness.light), - home: Scaffold(body: AgendaTimeline( - controller: app, - entries: entries, - now: _date.add(const Duration(hours: 10)), - )), - )); - await tester.pumpAndSettle(); - Finder card(String id) => find.byKey(ValueKey(('agenda-timeline-entry', id))); - Finder event(String id) => find.byWidgetPredicate( - (widget) => widget is EventTimelineEntry && widget.entry.id == id, - ); - Finder bar(String id) => find.byKey(ValueKey(('agenda-timeline-bar', id))); - expect(bar('morning'), findsOneWidget); - final shown = tester.widget(event('morning')); - expect(shown.showInformation, isTrue); - expect( - shown.geometry.startX - shown.geometry.informationLeft, - closeTo(48 * 3.75, .001), - ); - // The information lead makes back-to-back intervals overlap in layout. - expect( - tester.getRect(card('morning')).top, - isNot(closeTo(tester.getRect(card('afternoon')).top, .001)), - ); + testWidgets( + 'The switch restores event boxes and their text, then information bars', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final entries = [ + _timed('morning', 11, 13), + _timed('afternoon', 13, 15, kind: AgendaKind.exam), + AgendaEntry( + id: 'ddl', + title: 'ddl', + kind: AgendaKind.deadline, + startsAt: _date.add(const Duration(hours: 15)), + ), + ]; + final app = TestAppController(entries: entries); + await app.initialize(); + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: AgendaTimeline( + controller: app, + entries: entries, + now: _date.add(const Duration(hours: 12)), + ), + ), + ), + ); + await tester.pumpAndSettle(); + // Give the two-hour events enough screen space for the legacy card text. + tester.widget(find.byType(Slider)).onChanged!(24); + await tester.pumpAndSettle(); + Finder card(String id) => + find.byKey(ValueKey(('agenda-timeline-entry', id))); + Finder event(String id) => find.byWidgetPredicate( + (widget) => widget is EventTimelineEntry && widget.entry.id == id, + ); + Finder bar(String id) => + find.byKey(ValueKey(('agenda-timeline-bar', id))); + expect(bar('morning'), findsOneWidget); + final shown = tester.widget(event('morning')); + expect(shown.showInformation, isTrue); + expect( + shown.geometry.startX - shown.geometry.informationLeft, + closeTo(48 * 3.75, .001), + ); + // The information lead makes back-to-back intervals overlap in layout. + expect( + tester.getRect(card('morning')).top, + isNot(closeTo(tester.getRect(card('afternoon')).top, .001)), + ); - await app.settings.setAgendaEventInfoBar(false); - await tester.pumpAndSettle(); - expect(bar('morning'), findsNothing); - expect(bar('afternoon'), findsNothing); - final hidden = tester.widget(event('morning')); - expect(hidden.showInformation, isFalse); - // The painted surface still keeps exactly one line width left of the - // real start so the start indicator stays visible. - expect(hidden.geometry.informationLeft, closeTo(0, .001)); - expect(hidden.geometry.startX, closeTo(3, .001)); - // Without the lead the adjacent intervals share one lane. - expect( - tester.getRect(card('morning')).top, - closeTo(tester.getRect(card('afternoon')).top, .001), - ); - // Deadlines keep their own information cards. - expect(bar('ddl'), findsOneWidget); + await app.settings.setAgendaEventInfoBar(false); + await tester.pumpAndSettle(); + expect(bar('morning'), findsOneWidget); + expect(bar('afternoon'), findsOneWidget); + expect(event('morning'), findsNothing); + for (final id in ['morning', 'afternoon']) { + final legacy = find.descendant( + of: card(id), + matching: find.byType(AgendaTimelineEntry), + ); + expect(legacy, findsOneWidget); + expect( + find.descendant(of: legacy, matching: find.text(id)), + findsOneWidget, + ); + expect(tester.getRect(bar(id)).width, closeTo(96, .001)); + } + // Without the lead the adjacent intervals share one lane. + expect( + tester.getRect(card('morning')).top, + closeTo(tester.getRect(card('afternoon')).top, .001), + ); + // Deadlines keep their own information cards. + expect(bar('ddl'), findsOneWidget); - await app.settings.setAgendaEventInfoBar(true); - await tester.pumpAndSettle(); - expect(bar('morning'), findsOneWidget); - final restored = tester.widget(event('morning')); - expect( - restored.geometry.startX - restored.geometry.informationLeft, - closeTo(48 * 3.75, .001), - ); - expect( - tester.getRect(card('morning')).top, - isNot(closeTo(tester.getRect(card('afternoon')).top, .001)), - ); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox()); - }); + await app.settings.setAgendaEventInfoBar(true); + await tester.pumpAndSettle(); + expect(bar('morning'), findsOneWidget); + final restored = tester.widget(event('morning')); + expect( + restored.geometry.startX - restored.geometry.informationLeft, + closeTo(48 * 3.75, .001), + ); + expect( + tester.getRect(card('morning')).top, + isNot(closeTo(tester.getRect(card('afternoon')).top, .001)), + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); - testWidgets('Settings switches the event information bar for events and exams', ( - tester, - ) async { - final preferences = MemoryPreferences(); - final controller = TestAppController(preferences: preferences); - await controller.initialize(); - await tester.pumpWidget(MaterialApp( - theme: bitreeTheme(Brightness.light), - home: Scaffold(body: SettingsScreen( - controller: controller, - checkingForUpdates: false, - onCheckForUpdates: () {}, - )), - )); - await tester.pump(); - final switcher = find.byKey(const ValueKey('agenda-event-info-bar-settings')); - // The settings list builds lazily, so scroll until the row exists. - await tester.scrollUntilVisible( - switcher, - 200, - scrollable: find.byType(Scrollable).first, - ); - await tester.pumpAndSettle(); - expect(tester.widget(switcher).value, isTrue); - await tester.tap(switcher); - await tester.pumpAndSettle(); - expect(controller.settings.agendaEventInfoBar.value, isFalse); - expect(preferences.value(_key), 'false'); - await tester.tap(switcher); - await tester.pumpAndSettle(); - expect(controller.settings.agendaEventInfoBar.value, isTrue); - expect(preferences.value(_key), 'true'); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox()); - }); + testWidgets( + 'Settings switches the event information bar for events and exams', + (tester) async { + final preferences = MemoryPreferences(); + final controller = TestAppController(preferences: preferences); + await controller.initialize(); + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: SettingsScreen( + controller: controller, + checkingForUpdates: false, + onCheckForUpdates: () {}, + ), + ), + ), + ); + await tester.pump(); + final switcher = find.byKey( + const ValueKey('agenda-event-info-bar-settings'), + ); + // The settings list builds lazily, so scroll until the row exists. + await tester.scrollUntilVisible( + switcher, + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + expect(tester.widget(switcher).value, isTrue); + await tester.tap(switcher); + await tester.pumpAndSettle(); + expect(controller.settings.agendaEventInfoBar.value, isFalse); + expect(preferences.value(_key), 'false'); + await tester.tap(switcher); + await tester.pumpAndSettle(); + expect(controller.settings.agendaEventInfoBar.value, isTrue); + expect(preferences.value(_key), 'true'); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); } diff --git a/test/storage_compatibility_test.dart b/test/storage_compatibility_test.dart index 86277bd..5e29feb 100644 --- a/test/storage_compatibility_test.dart +++ b/test/storage_compatibility_test.dart @@ -180,7 +180,14 @@ void main() { expect(restored.tailDays, 14); expect(restored.copyWith(done: false, deleted: false).tailDays, 14); final legacy = deadline.toJson()..remove('tailDays'); - expect(AgendaEntry.fromJson(legacy).tailDays, AgendaEntry.defaultTailDays); + final inherited = AgendaEntry.fromJson(legacy); + expect(inherited.tailDays, isNull); + expect(inherited.effectiveLayoutDays(9), 9); + expect( + AgendaEntry.fromJson({...legacy, 'tailDays': 4}).effectiveLayoutDays(9), + 4, + ); + expect(AgendaEntry.fromJson(inherited.toJson()).tailDays, isNull); for (final invalid in [0, -1, 31, 1.5, '7']) { expect( () => AgendaEntry.fromJson({...legacy, 'tailDays': invalid}), From cd31518883b88f88c62f7e143ffb11adc53d3197 Mon Sep 17 00:00:00 2001 From: dongdigua Date: Thu, 12 Nov 2026 05:00:04 +0000 Subject: [PATCH 22/24] feat(settings): add manual migration for default DDL tails Expose a developer action to reset four-day DDL tails to null, including recycled entries, while retaining other widths and reporting the migrated count. Keep inheritance in the existing tailDays field and cover default editor state, persistence, repeated migration, and write failure recovery. (cherry picked from commit 34bbb877ec5296c530fcf3c0b240c12a3a7e3fcd) --- docs/architecture.md | 4 +- docs/deadline-cutoff.md | 4 +- lib/agenda/controller.dart | 28 +++- lib/agenda/model.dart | 8 +- lib/app/ui/settings_screen.dart | 26 ++++ lib/l10n/app_en.arb | 9 ++ lib/l10n/app_localizations.dart | 24 ++++ lib/l10n/app_localizations_en.dart | 22 +++ lib/l10n/app_localizations_zh.dart | 15 ++ lib/l10n/app_zh.arb | 4 + test/agenda_detail_test.dart | 75 +++++++++- test/deadline_tail_migration_test.dart | 192 +++++++++++++++++++++++++ test/developer_settings_test.dart | 10 +- test/storage_compatibility_test.dart | 11 ++ 14 files changed, 423 insertions(+), 9 deletions(-) create mode 100644 test/deadline_tail_migration_test.dart diff --git a/docs/architecture.md b/docs/architecture.md index e22fa67..48ecf18 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,7 +82,7 @@ USTC 风控采集失败时遵循学校登录页的协议:仅在风控请求出 日程页提供列表与横向多泳道时间轴,共用类型筛选,读取同一个 `AgendaController`,不混入课表课程。列表保留未完成/已完成分页;时间轴将两种状态同表排布,不按完成状态分配泳道,已完成或已结束条目仍沿用弱化样式。视图模式通过 `SharedPreferencesAsync` 的 `agendaViewMode` 保存,缺省为瀑布图(时间轴),已有有效偏好仍按用户选择恢复;缩放和浏览位置不写入日程文件。切换视图保留本次浏览位置,离开日程页后再进入会将时间轴定位到今日 12:00 居中。 -`agenda/timeline_layout.dart` 将完整泳道预留与缩放绘制投影分开。普通 DDL 预留 `[有效截止-D天,真实截止]`,仅开始项目向右预留 D 天,普通日程和考试预留 `[真实开始-D天,真实结束]`;数据和基础布局参数不变时,缩放和平移不会重新分行。D 优先使用单条 DDL 的 `tailDays`;没有单独设置(`tailDays` 缺失或为 null)时跟随全局默认排版宽度,初始 4、范围 1~30 天。旧记录中已保存的单条长度保留优先级,编辑页“更多选项”提供拖尾滑条和“跟随全局设置”开关。普通日程和考试的信息区继续使用全局默认值。DDL 主体和日程左侧信息区按最小缩放 D−0.25 天对应的屏幕像素定宽,默认约 3.75 基线天,不采用 144px 独立正文或随缩放增长的日历天数拖尾。`paintStart/paintEnd` 表示当前实际绘制边界,真实时段和伪截止延伸段随缩放变化。纵向范围取全部筛选条目的最高泳道,不依赖横向可见条目。 +`agenda/timeline_layout.dart` 将完整泳道预留与缩放绘制投影分开。普通 DDL 预留 `[有效截止-D天,真实截止]`,仅开始项目向右预留 D 天,普通日程和考试预留 `[真实开始-D天,真实结束]`;数据和基础布局参数不变时,缩放和平移不会重新分行。D 优先使用单条 DDL 的 `tailDays`;没有单独设置(`tailDays` 缺失或为 null)时跟随全局默认排版宽度,初始 4、范围 1~30 天。旧记录中已保存的单条长度保留优先级。编辑页“更多选项”提供拖尾滑条和“跟随全局设置”开关。普通日程和考试的信息区继续使用全局默认值。DDL 主体和日程左侧信息区按最小缩放 D−0.25 天对应的屏幕像素定宽,默认约 3.75 基线天,不采用 144px 独立正文或随缩放增长的日历天数拖尾。`paintStart/paintEnd` 表示当前实际绘制边界,真实时段和伪截止延伸段随缩放变化。纵向范围取全部筛选条目的最高泳道,不依赖横向可见条目。 `AgendaTimelineEntry` 只负责条目的外观和交互,不处理坐标或泳道。日程与考试在标题下常驻真实起止时间,跨天时分两行标出日期;全天日程显示日期范围。DDL 保留课程/来源,并另列截止时间。长按或鼠标悬停显示包含开始、结束或截止日期、课程及地点的分行预览;点按沿用详情抽屉。卡片最小高度为 56 逻辑像素,并随字体放大,确保完整时间与来源信息有独立空间。 @@ -95,7 +95,7 @@ DDL 主体右端为有效 `dueAt`,从左侧渐隐;仅有开放时间的项 主页课表网格中的日程块不复用时间轴的语义来源色:普通活动使用 `primaryContainer` 背景、`onPrimaryContainer` 文字和 1px `primary` 边框,考试使用 `errorContainer`/`onErrorContainer`/`error`。浅色模式下容器色与页面底色天然接近,可见性由 tone 固定的边框和文字承担,因此开启全局系统调色板后仍能辨认;不使用 `outlineVariant` 等低对比细分隔色。 关闭全局系统调色板时,所选 JSON 调色板只覆盖课程与普通日程块,不修改页面背景、全局 `ColorScheme` 或应用明暗模式。内置 Mocha/Macchiato/Frappé 位于 `assets/palettes/`,颜色来自 [Catppuccin palette](https://github.com/catppuccin/palette/blob/main/palette.json),均适用于深色模式。课程使用所属区域的强调色,普通日程使用 `agenda`;最终底色统一为 `Color.lerp(base, accent, 0.24)`,文字统一使用 `text`。主页普通日程块与时间轴普通活动、无平台来源的手动 DDL 使用同一组颜色。时间轴中的平台作业、MOOC、物理实验与考试继续保留来源映射;未完成且逾期的 DDL 使用轻量错误色蒙版,主页考试沿用错误色,完成淡化规则不变。系统调色板可用且已启用时优先于 JSON 配色。 -设置的「外观」二级页面提供明暗模式切换、浅色/深色调色板独立选择、JSON 文件导入、自定义配色的删除按钮及系统调色板开关,所有选项均可直接使用。关于弹窗中的五次连击仍可开启本次运行的开发者模式,但目前不影响任何功能。导入成功后将完整配色内容复制到应用设置并应用到对应模式,不依赖原文件继续存在;校验或保存失败时保留原选择。删除正在使用的自定义配色会将对应模式恢复为默认。旧版本的 Cat 配色选择会自动恢复。Android 小组件接收浅色和深色课程的最终颜色,其系统课程配色开关仍独立控制;Android 12+ 的组件整体背景与文字继续使用系统颜色。文件格式与字段说明见 [调色板文件](palettes.md)。 +设置的「外观」二级页面提供明暗模式切换、浅色/深色调色板独立选择、JSON 文件导入、自定义配色的删除按钮及系统调色板开关,所有选项均可直接使用。关于弹窗中的五次连击可开启本次运行的开发者模式,设置页随后显示“开发者选项”;其中“将默认拖尾长度设为 null”按钮会将所有 4 天的 DDL 拖尾(含回收站)一次性改为跟随全局设置,其它长度保留,不自动迁移。导入成功后将完整配色内容复制到应用设置并应用到对应模式,不依赖原文件继续存在;校验或保存失败时保留原选择。删除正在使用的自定义配色会将对应模式恢复为默认。旧版本的 Cat 配色选择会自动恢复。Android 小组件接收浅色和深色课程的最终颜色,其系统课程配色开关仍独立控制;Android 12+ 的组件整体背景与文字继续使用系统颜色。文件格式与字段说明见 [调色板文件](palettes.md)。 操作方式:拖动或普通滚轮横向浏览时间,双指捏合、`Ctrl + 滚轮` 或滑条连续缩放;上下拖动或 `Shift + 滚轮` 浏览泳道。时间轴隐藏显式与桌面自动添加的纵向滚动条,仍保留纵向滚动和惯性。纵向拖动通过 `ScrollPosition.drag/update/end` 使用 Flutter 的惯性和回弹,横向中心日期使用摩擦模拟;正常 overscroll 保留,筛选或尺寸缩小导致的旧偏移与卡片裁剪同步纠正。平移中心日期通过局部监听更新视口,网格文字和卡片内容层复用缓存,动态几何独立更新;缩放、新输入、隐藏视图或离开页面时停止惯性,触控板自身的惯性输入不额外叠加甩动。「今天」按钮回到今日居中。日程列表的完成状态以分段选择过滤,不使用横向分页;时间轴的横拖只用于时间导航。 diff --git a/docs/deadline-cutoff.md b/docs/deadline-cutoff.md index 1939712..02b9bbd 100644 --- a/docs/deadline-cutoff.md +++ b/docs/deadline-cutoff.md @@ -33,8 +33,10 @@ DDL 一致的原生 Material 时间选择器,复用 `appPickerDialogBuilder` 命中窗口的 DDL: - 排版宽度 `D` 优先使用单条 DDL 的 `tailDays`(1–30 天)。未单独设置时 - 跟随全局默认值(初始为 4 天);旧记录中已保存的单条长度继续生效。 + 跟随全局默认值(初始为 4 天);已有单条长度(包括 4 天)读取时保持原值。 编辑页“更多选项”可调整拖尾滑条,或恢复“跟随全局设置”。 + 开发者选项提供手动迁移按钮,将所有 4 天的 DDL 拖尾(含回收站)设为 + `null`,其它长度不变;不增加额外字段,不在读取时自动迁移。 - 预留区间为 `[有效截止 − D 天, 真实截止]`。 - 左侧主体为最小缩放 `D − 0.25` 天的屏幕定宽,不随缩放增长(默认 3.75 天);右边界位于有效截止。 diff --git a/lib/agenda/controller.dart b/lib/agenda/controller.dart index c2a2842..a07ea5d 100644 --- a/lib/agenda/controller.dart +++ b/lib/agenda/controller.dart @@ -42,8 +42,9 @@ class AgendaController extends ChangeNotifier { Iterable get entries => _entries.where((entry) => !entry.deleted).map(_present); - Iterable get deletedDeadlines => - _entries.where((entry) => entry.deleted && entry.isDeadline).map(_present); + Iterable get deletedDeadlines => _entries + .where((entry) => entry.deleted && entry.isDeadline) + .map(_present); List pendingOn(DateTime date, {required DateTime now}) => entries @@ -71,6 +72,29 @@ class AgendaController extends ChangeNotifier { (entries) => [...entries.where((item) => item.id != entry.id), entry], ); + /// Manually migrate old four-day DDL tails, including recycled entries. + /// Returns zero without writing when repeated, or null if persistence fails. + Future resetDefaultDeadlineTails() => _operations.run(() async { + if (_disposed) return null; + if (!_ready) { + _reportNotReady(); + return null; + } + var count = 0; + final next = []; + for (final entry in _entries) { + if (entry.isDeadline && entry.tailDays == AgendaEntry.defaultTailDays) { + count++; + next.add(entry.copyWith(tailDays: null)); + } else { + next.add(entry); + } + } + if (count == 0) return 0; + final saved = await _commit(next); + return saved ? count : null; + }); + Future restore(AgendaEntry entry) => restoreEntries([entry]); /// Restores saved entries in one write and re-inserts records that were diff --git a/lib/agenda/model.dart b/lib/agenda/model.dart index d1067a6..06b98ae 100644 --- a/lib/agenda/model.dart +++ b/lib/agenda/model.dart @@ -10,6 +10,8 @@ export 'details.dart'; enum AgendaKind { event, deadline, exam } +const _copyUnset = Object(); + /// Times use the same Beijing wall-clock convention as campusNow(). class AgendaEntry { AgendaEntry({ @@ -155,7 +157,7 @@ class AgendaEntry { AgendaEntry copyWith({ bool? done, bool? deleted, - int? tailDays, + Object? tailDays = _copyUnset, DeadlineCutoffWindow? deadlineCutoffWindow, }) => AgendaEntry( id: id, @@ -175,7 +177,9 @@ class AgendaEntry { url: url, color: color, details: details, - tailDays: tailDays ?? this.tailDays, + tailDays: identical(tailDays, _copyUnset) + ? this.tailDays + : tailDays as int?, startOnly: startOnly, deadlineCutoffWindow: deadlineCutoffWindow ?? this.deadlineCutoffWindow, ); diff --git a/lib/app/ui/settings_screen.dart b/lib/app/ui/settings_screen.dart index 53fe2c5..55259fc 100644 --- a/lib/app/ui/settings_screen.dart +++ b/lib/app/ui/settings_screen.dart @@ -66,6 +66,17 @@ class SettingsScreen extends StatelessWidget { } } + Future _resetDefaultDeadlineTails(BuildContext context) async { + final count = await controller.agenda.resetDefaultDeadlineTails(); + if (!context.mounted) return; + showAppNotice( + context, + count == null + ? context.l10n.operationSaveFailed + : context.l10n.settingsResetDefaultDeadlineTailsResult(count), + ); + } + @override Widget build(BuildContext context) => ListenableBuilder( listenable: Listenable.merge([ @@ -278,6 +289,21 @@ class SettingsScreen extends StatelessWidget { onTap: () => showBitreeAboutDialog(context, controller.settings), ), ]), + if (controller.settings.developerMode) + section(l10n.settingsDeveloperOptions, [ + ListTile( + key: const ValueKey('reset-default-deadline-tails'), + contentPadding: tilePadding, + leading: const Icon(Icons.restart_alt_rounded), + title: Text(l10n.settingsResetDefaultDeadlineTails), + subtitle: Text( + l10n.settingsResetDefaultDeadlineTailsDescription, + ), + onTap: !controller.agenda.ready || controller.agenda.busy + ? null + : () => _resetDefaultDeadlineTails(context), + ), + ]), ], ), ); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 483fea9..a942d3e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -14,6 +14,15 @@ "aboutIconSemantics": "BITree icon", "aboutLicenseSummary": "Made by BIToolkit with ♥️. Released under the Unlicense.", "developerModeEnabled": "Developer mode enabled", + "settingsDeveloperOptions": "Developer options", + "settingsResetDefaultDeadlineTails": "Set default tail lengths to null", + "settingsResetDefaultDeadlineTailsDescription": "Make all four-day DDL tails follow the global setting.", + "settingsResetDefaultDeadlineTailsResult": "{count, plural, =1{1 DDL now follows the global setting.} other{{count} DDLs now follow the global setting.}}", + "@settingsResetDefaultDeadlineTailsResult": { + "placeholders": { + "count": {"type": "int"} + } + }, "widgetUsesSystemPalette": "Use system palette for widget", "globalSystemPaletteExperimental": "Use system palette throughout app (experimental)", "importPalette": "Import palette", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5071049..51b4097 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -182,6 +182,30 @@ abstract class AppLocalizations { /// **'Developer mode enabled'** String get developerModeEnabled; + /// No description provided for @settingsDeveloperOptions. + /// + /// In en, this message translates to: + /// **'Developer options'** + String get settingsDeveloperOptions; + + /// No description provided for @settingsResetDefaultDeadlineTails. + /// + /// In en, this message translates to: + /// **'Set default tail lengths to null'** + String get settingsResetDefaultDeadlineTails; + + /// No description provided for @settingsResetDefaultDeadlineTailsDescription. + /// + /// In en, this message translates to: + /// **'Make all four-day DDL tails follow the global setting.'** + String get settingsResetDefaultDeadlineTailsDescription; + + /// No description provided for @settingsResetDefaultDeadlineTailsResult. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 DDL now follows the global setting.} other{{count} DDLs now follow the global setting.}}'** + String settingsResetDefaultDeadlineTailsResult(int count); + /// No description provided for @widgetUsesSystemPalette. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 4055fe3..7718067 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -53,6 +53,28 @@ class AppLocalizationsEn extends AppLocalizations { @override String get developerModeEnabled => 'Developer mode enabled'; + @override + String get settingsDeveloperOptions => 'Developer options'; + + @override + String get settingsResetDefaultDeadlineTails => + 'Set default tail lengths to null'; + + @override + String get settingsResetDefaultDeadlineTailsDescription => + 'Make all four-day DDL tails follow the global setting.'; + + @override + String settingsResetDefaultDeadlineTailsResult(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count DDLs now follow the global setting.', + one: '1 DDL now follows the global setting.', + ); + return '$_temp0'; + } + @override String get widgetUsesSystemPalette => 'Use system palette for widget'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index d5c6fd1..78f17e0 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -51,6 +51,21 @@ class AppLocalizationsZh extends AppLocalizations { @override String get developerModeEnabled => '已开启开发者模式'; + @override + String get settingsDeveloperOptions => '开发者选项'; + + @override + String get settingsResetDefaultDeadlineTails => '将默认拖尾长度设为 null'; + + @override + String get settingsResetDefaultDeadlineTailsDescription => + '将所有 4 天的 DDL 拖尾改为跟随全局设置。'; + + @override + String settingsResetDefaultDeadlineTailsResult(int count) { + return '已将 $count 个 DDL 改为跟随全局设置。'; + } + @override String get widgetUsesSystemPalette => '小组件使用系统调色板'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 96ccbc1..86fbd37 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -14,6 +14,10 @@ "aboutIconSemantics": "BITree 图标", "aboutLicenseSummary": "由 BIToolkit 制作 ♥️,以 Unlicense 发布。", "developerModeEnabled": "已开启开发者模式", + "settingsDeveloperOptions": "开发者选项", + "settingsResetDefaultDeadlineTails": "将默认拖尾长度设为 null", + "settingsResetDefaultDeadlineTailsDescription": "将所有 4 天的 DDL 拖尾改为跟随全局设置。", + "settingsResetDefaultDeadlineTailsResult": "已将 {count} 个 DDL 改为跟随全局设置。", "widgetUsesSystemPalette": "小组件使用系统调色板", "globalSystemPaletteExperimental": "全局系统调色板(实验性)", "importPalette": "导入调色板", diff --git a/test/agenda_detail_test.dart b/test/agenda_detail_test.dart index 5ea507e..9b796e9 100644 --- a/test/agenda_detail_test.dart +++ b/test/agenda_detail_test.dart @@ -5,6 +5,7 @@ import 'package:bitree/agenda/controller.dart'; import 'package:bitree/agenda/deadline_source.dart'; import 'package:bitree/agenda/model.dart'; import 'package:bitree/agenda/ui/agenda_detail.dart'; +import 'package:bitree/agenda/ui/agenda_editor.dart'; import 'package:bitree/agenda/ui/completion_text.dart'; import 'package:bitree/l10n/app_localizations_zh.dart'; import 'package:bitree/l10n/formats.dart'; @@ -24,7 +25,7 @@ void main() { AgendaEntry deadline({ bool detailed = false, bool allDay = false, - int tailDays = 4, + int? tailDays, }) => AgendaEntry( id: 'assignment', title: detailed ? '第三章作业:线性表与链表的实现、测试和复杂度分析' : '1', @@ -291,6 +292,78 @@ void main() { }, ); + testWidgets( + 'New DDLs follow the global tail default before and after saving', + (tester) async { + final controller = await open(tester, deadline()); + await controller.settings.setAgendaLayoutDays(8); + await tester.tap(find.byTooltip('关闭')); + await tester.pumpAndSettle(); + unawaited( + editAgenda( + tester.element(find.text('打开详情')), + controller.agenda, + kind: AgendaKind.deadline, + ), + ); + await tester.pumpAndSettle(); + final name = find.byWidgetPredicate( + (widget) => widget is TextField && widget.decoration?.labelText == '名称', + ); + await tester.enterText(name, '默认拖尾'); + await tester.tap(find.byKey(const ValueKey('agenda-more-options'))); + await tester.pumpAndSettle(); + final useDefault = find.byKey(const ValueKey('agenda-tail-days-default')); + expect(tester.widget(useDefault).value, isTrue); + expect( + find.byKey(const ValueKey('agenda-tail-days-slider')), + findsNothing, + ); + await tester.tap(find.widgetWithText(FilledButton, '保存')); + await tester.pumpAndSettle(); + final saved = controller.agenda.entries.singleWhere( + (entry) => entry.title == '默认拖尾', + ); + expect(saved.tailDays, isNull); + expect( + saved.effectiveLayoutDays(controller.settings.agendaLayoutDays.value), + 8, + ); + expect(AgendaEntry.fromJson(saved.toJson()).tailDays, isNull); + expect(tester.takeException(), isNull); + }, + ); + + for (final missingField in [false, true]) { + testWidgets( + 'Unset tail stays inherited when editing, missingField=$missingField', + (tester) async { + final raw = deadline().toJson(); + if (missingField) raw.remove('tailDays'); + final controller = await open(tester, AgendaEntry.fromJson(raw)); + await tester.tap(find.widgetWithText(TextButton, '编辑')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('agenda-more-options'))); + await tester.pumpAndSettle(); + final useDefault = find.byKey( + const ValueKey('agenda-tail-days-default'), + ); + expect(tester.widget(useDefault).value, isTrue); + expect( + find.byKey(const ValueKey('agenda-tail-days-slider')), + findsNothing, + ); + await tester.tap(find.widgetWithText(FilledButton, '保存')); + await tester.pumpAndSettle(); + final saved = controller.agenda.entries.single; + expect(saved.tailDays, isNull); + expect(saved.toJson()['tailDays'], isNull); + expect(saved.effectiveLayoutDays(8), 8); + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets('An individual tail can return to following the global default', ( tester, ) async { diff --git a/test/deadline_tail_migration_test.dart b/test/deadline_tail_migration_test.dart new file mode 100644 index 0000000..d6ab7b3 --- /dev/null +++ b/test/deadline_tail_migration_test.dart @@ -0,0 +1,192 @@ +import 'dart:async'; + +import 'package:bitree/agenda/controller.dart'; +import 'package:bitree/agenda/model.dart'; +import 'package:bitree/app/ui/settings_screen.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 _date = DateTime.utc(2030, 1, 2, 12); + +AgendaEntry _deadline( + String id, + int? tailDays, { + bool deleted = false, + bool startOnly = false, +}) => AgendaEntry( + id: id, + title: id, + kind: AgendaKind.deadline, + startsAt: _date, + notes: 'keep notes', + done: true, + deleted: deleted, + startOnly: startOnly, + tailDays: tailDays, +); + +class _MigrationStore extends MemoryAgendaStore { + _MigrationStore(super.entries); + + int writes = 0; + bool fail = false; + Completer? release; + + @override + Future write(List entries) async { + writes++; + if (release case final pending?) await pending.future; + if (fail) throw Exception('simulated write failure'); + await super.write(entries); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'Manual migration only resets four-day DDL tails in one serialized write', + () async { + final original = [ + _deadline('plain', 4), + _deadline('open', 4, startOnly: true), + _deadline('recycled', 4, deleted: true), + _deadline('custom', 14), + _deadline('inherited', null), + AgendaEntry( + id: 'event', + title: 'event', + kind: AgendaKind.event, + startsAt: _date, + endsAt: _date.add(const Duration(hours: 1)), + tailDays: 4, + ), + ]; + final store = _MigrationStore(original); + final controller = AgendaController(store); + addTearDown(controller.dispose); + await controller.load(); + // Merely loading never migrates an existing four-day value. + expect(controller.entries.first.tailDays, 4); + expect(store.writes, 0); + + expect( + await Future.wait([ + controller.resetDefaultDeadlineTails(), + controller.resetDefaultDeadlineTails(), + ]), + [3, 0], + ); + expect(store.writes, 1); + for (var i = 0; i < original.length; i++) { + final before = original[i]; + final expected = before.toJson(); + if (before.isDeadline && before.tailDays == 4) { + expected['tailDays'] = null; + } + expect(store.entries[i].toJson(), expected); + } + expect(controller.deletedDeadlines.single.tailDays, isNull); + expect(controller.deletedDeadlines.single.deleted, isTrue); + + final restarted = AgendaController(store); + addTearDown(restarted.dispose); + await restarted.load(); + expect( + restarted.entries.singleWhere((e) => e.id == 'plain').tailDays, + isNull, + ); + expect( + restarted.entries.singleWhere((e) => e.id == 'custom').tailDays, + 14, + ); + expect(await restarted.resetDefaultDeadlineTails(), 0); + expect(store.writes, 1); + }, + ); + + test( + 'A failed migration keeps the original state and can be retried', + () async { + final store = _MigrationStore([_deadline('plain', 4)]); + final errors = []; + final controller = AgendaController( + store, + reportError: (_, error, _) => errors.add(error), + ); + addTearDown(controller.dispose); + await controller.load(); + store.fail = true; + expect(await controller.resetDefaultDeadlineTails(), isNull); + expect(errors, hasLength(1)); + expect(controller.entries.single.tailDays, 4); + expect(store.entries.single.tailDays, 4); + expect(controller.busy, isFalse); + store.fail = false; + expect(await controller.resetDefaultDeadlineTails(), 1); + expect(controller.entries.single.tailDays, isNull); + expect(store.entries.single.tailDays, isNull); + }, + ); + + testWidgets( + 'The migration button appears only in developer mode and reports the saved count', + (tester) async { + final store = _MigrationStore([ + _deadline('plain', 4), + _deadline('custom', 14), + ]); + final agenda = AgendaController(store); + final app = TestAppController(agenda: agenda); + await app.initialize(); + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: SettingsScreen( + controller: app, + checkingForUpdates: false, + onCheckForUpdates: () {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final button = find.byKey(const ValueKey('reset-default-deadline-tails')); + expect(button, findsNothing); + app.settings.unlockDeveloperMode(); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + button, + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + expect(find.text('开发者选项'), findsOneWidget); + expect(tester.widget(button).onTap, isNotNull); + + final release = Completer(); + store.release = release; + await tester.tap(button); + await tester.pump(); + expect(tester.widget(button).onTap, isNull); + release.complete(); + await tester.pumpAndSettle(); + expect(store.entries.first.tailDays, isNull); + expect(store.entries.last.tailDays, 14); + expect(find.text('已将 1 个 DDL 改为跟随全局设置。'), findsOneWidget); + expect(tester.widget(button).onTap, isNotNull); + + await tester.tap(button); + await tester.pumpAndSettle(); + expect(find.text('已将 0 个 DDL 改为跟随全局设置。'), findsOneWidget); + expect(store.writes, 1); + await tester.pump(const Duration(seconds: 3)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); +} diff --git a/test/developer_settings_test.dart b/test/developer_settings_test.dart index 0db1b84..eff3452 100644 --- a/test/developer_settings_test.dart +++ b/test/developer_settings_test.dart @@ -74,7 +74,7 @@ void main() { }); testWidgets( - 'Five consecutive About icon taps keep developer mode without revealing options', + 'Five consecutive About icon taps reveal developer migration options', (tester) async { PackageInfo.setMockInitialValues( appName: 'BITree', @@ -144,6 +144,14 @@ void main() { expect(find.text('深色模式调色板'), findsNothing); expect(find.text('小组件使用系统调色板'), findsNothing); expect(find.text('全局系统调色板(实验性)'), findsNothing); + await tester.scrollUntilVisible( + find.byKey(const ValueKey('reset-default-deadline-tails')), + 200, + scrollable: scrollable, + ); + await tester.pumpAndSettle(); + expect(find.text('开发者选项'), findsOneWidget); + expect(find.text('将默认拖尾长度设为 null'), findsOneWidget); await tester.scrollUntilVisible( find.text('外观'), -200, diff --git a/test/storage_compatibility_test.dart b/test/storage_compatibility_test.dart index 5e29feb..31ffeef 100644 --- a/test/storage_compatibility_test.dart +++ b/test/storage_compatibility_test.dart @@ -187,6 +187,17 @@ void main() { AgendaEntry.fromJson({...legacy, 'tailDays': 4}).effectiveLayoutDays(9), 4, ); + expect( + AgendaEntry.fromJson({...legacy, 'tailDays': 14}).effectiveLayoutDays(9), + 14, + ); + final explicitFour = deadline.copyWith(tailDays: 4); + expect( + AgendaEntry.fromJson(explicitFour.toJson()).effectiveLayoutDays(9), + 4, + ); + expect(explicitFour.copyWith(tailDays: null).tailDays, isNull); + expect(explicitFour.copyWith().tailDays, 4); expect(AgendaEntry.fromJson(inherited.toJson()).tailDays, isNull); for (final invalid in [0, -1, 31, 1.5, '7']) { expect( From de48ca73c01b3af9297ab8e9768606885906b6db Mon Sep 17 00:00:00 2001 From: dongdigua Date: Thu, 12 Nov 2026 05:41:34 +0000 Subject: [PATCH 23/24] refactor(agenda): share timeline captions and tail day bounds Paint every timeline card style from one caption helper, and let the existing AgendaEntry constants be the only source for the one to thirty day tail range. The preview status line keeps its isFinished branch: on this branch finishing a card still follows elapsed time, so the ended state is reachable and must stay. Card text, semantics, validation, persistence and error copy stay identical; the shared day marker also replaces the grid header's own copy. (cherry picked from commit 619fe86a599f905f16ec129adcca22e6974a86ce) --- lib/agenda/model.dart | 6 ++- lib/agenda/timeline_layout.dart | 5 ++- lib/agenda/ui/agenda_editor_sections.dart | 4 +- lib/agenda/ui/agenda_timeline.dart | 1 + lib/agenda/ui/agenda_timeline_painters.dart | 2 +- lib/agenda/ui/event_timeline_entry.dart | 22 ++--------- lib/agenda/ui/timeline_caption.dart | 42 +++++++++++++++++++++ lib/agenda/ui/timeline_entry.dart | 32 +--------------- lib/app/settings_controller.dart | 22 ++++++++--- lib/app/ui/agenda_layout_days_settings.dart | 11 ++++-- 10 files changed, 82 insertions(+), 65 deletions(-) create mode 100644 lib/agenda/ui/timeline_caption.dart diff --git a/lib/agenda/model.dart b/lib/agenda/model.dart index 06b98ae..3124fdb 100644 --- a/lib/agenda/model.dart +++ b/lib/agenda/model.dart @@ -46,8 +46,9 @@ class AgendaEntry { (endsAt == null || !endsAt!.isAfter(startsAt))) { throw const FormatException('结束时间必须晚于开始时间'); } - if (tailDays case final days? when days < 1 || days > maxTailDays) { - throw const FormatException('拖尾长度必须为 1–30 天'); + if (tailDays case final days? + when days < minTailDays || days > maxTailDays) { + throw FormatException('拖尾长度必须为 $minTailDays–$maxTailDays 天'); } if (color != null && kind == AgendaKind.exam) { throw const FormatException('考试颜色由状态决定'); @@ -78,6 +79,7 @@ class AgendaEntry { } static const defaultTailDays = 4; + static const minTailDays = 1; static const maxTailDays = 30; final String id; diff --git a/lib/agenda/timeline_layout.dart b/lib/agenda/timeline_layout.dart index 841caa9..87457b1 100644 --- a/lib/agenda/timeline_layout.dart +++ b/lib/agenda/timeline_layout.dart @@ -114,10 +114,11 @@ List<_LaneReservation> _reserveLanes( required int layoutDays, required bool reserveEventInformation, }) { - if (layoutDays < 1 || layoutDays > AgendaEntry.maxTailDays) { + if (layoutDays < AgendaEntry.minTailDays || + layoutDays > AgendaEntry.maxTailDays) { throw RangeError.range( layoutDays, - 1, + AgendaEntry.minTailDays, AgendaEntry.maxTailDays, 'layoutDays', ); diff --git a/lib/agenda/ui/agenda_editor_sections.dart b/lib/agenda/ui/agenda_editor_sections.dart index 1ff079f..75a507e 100644 --- a/lib/agenda/ui/agenda_editor_sections.dart +++ b/lib/agenda/ui/agenda_editor_sections.dart @@ -153,9 +153,9 @@ extension _AgendaEditorSections on _AgendaEditorState { child: Slider( key: const ValueKey('agenda-tail-days-slider'), value: days.toDouble(), - min: 1, + min: AgendaEntry.minTailDays.toDouble(), max: AgendaEntry.maxTailDays.toDouble(), - divisions: AgendaEntry.maxTailDays - 1, + divisions: AgendaEntry.maxTailDays - AgendaEntry.minTailDays, label: l10n.agendaTailDaysValue(days), semanticFormatterCallback: (value) => l10n.agendaTailDaysValue(value.round()), diff --git a/lib/agenda/ui/agenda_timeline.dart b/lib/agenda/ui/agenda_timeline.dart index d049f49..59f1bc0 100644 --- a/lib/agenda/ui/agenda_timeline.dart +++ b/lib/agenda/ui/agenda_timeline.dart @@ -14,6 +14,7 @@ import '../timeline_layout.dart'; import 'adjusted_deadline_painter.dart'; import 'agenda_detail.dart'; import 'event_timeline_entry.dart'; +import 'timeline_caption.dart'; import 'timeline_entry.dart'; part 'agenda_timeline_painters.dart'; diff --git a/lib/agenda/ui/agenda_timeline_painters.dart b/lib/agenda/ui/agenda_timeline_painters.dart index 23e7af7..d323afe 100644 --- a/lib/agenda/ui/agenda_timeline_painters.dart +++ b/lib/agenda/ui/agenda_timeline_painters.dart @@ -171,7 +171,7 @@ class _TimelineGridPainter extends CustomPainter { if (header && dayOrdinal % dayStride == 0) { final dayCenter = (dayX + x(nextDay)) / 2; final weekday = localizedShortWeekday(l10n, day.weekday); - final date = '${day.month}/${day.day}'; + final date = agendaTimelineShortDate(day); _label( canvas, size, diff --git a/lib/agenda/ui/event_timeline_entry.dart b/lib/agenda/ui/event_timeline_entry.dart index d6b343d..3ad564b 100644 --- a/lib/agenda/ui/event_timeline_entry.dart +++ b/lib/agenda/ui/event_timeline_entry.dart @@ -5,6 +5,7 @@ import '../../l10n/formats.dart'; import '../../l10n/l10n.dart'; import '../model.dart'; import 'adjusted_deadline_painter.dart'; +import 'timeline_caption.dart'; import 'timeline_colors.dart'; /// All coordinates are relative to a viewport-bounded card surface. @@ -163,14 +164,15 @@ class _EventTimelineEntryState extends State { if (entry.done) l10n.agendaTimelineStatusCompleted else if (overdue) l10n.agendaTimelineStatusOverdue, ].join('\n'); + final span = agendaTimelineCaption(l10n, entry); final caption = overdue ? l10n.agendaReminderOverdue(clockText(entry.endsAt!)) - : _eventCaption(l10n, entry); + : span; final semantics = l10n.agendaTimelineSemanticsEntry( entry.kind.localizedLabel(l10n), title, '${localizedAgendaDateTime(l10n, entry.startsAt)} ' - '${_eventCaption(l10n, entry).replaceAll('\n', ' ')}', + '${span.replaceAll('\n', ' ')}', entry.course.isEmpty ? '' : l10n.listSeparator + entry.course, entry.location.isEmpty ? '' : l10n.listSeparator + entry.location, entry.done @@ -262,19 +264,3 @@ class _EventTimelineEntryState extends State { ); } } - -String _eventCaption(AppLocalizations l10n, AgendaEntry entry) { - String day(DateTime value) => '${value.month}/${value.day}'; - final end = entry.endsAt!; - if (entry.allDay) { - final last = end.subtract(const Duration(days: 1)); - return calendarDate(last) == calendarDate(entry.startsAt) - ? l10n.allDay - : '${day(entry.startsAt)}–${day(last)}\n${l10n.allDay}'; - } - if (calendarDate(entry.startsAt) == calendarDate(end)) { - return '${clockText(entry.startsAt)}–${clockText(end)}'; - } - return '${day(entry.startsAt)} ${clockText(entry.startsAt)}\n' - '→ ${day(end)} ${clockText(end)}'; -} diff --git a/lib/agenda/ui/timeline_caption.dart b/lib/agenda/ui/timeline_caption.dart new file mode 100644 index 0000000..feea26b --- /dev/null +++ b/lib/agenda/ui/timeline_caption.dart @@ -0,0 +1,42 @@ +import '../../core/time.dart'; +import '../../l10n/l10n.dart'; +import '../model.dart'; + +/// Short `11/3` day marker shared by the wrapped timeline captions and the +/// timeline grid header. +String agendaTimelineShortDate(DateTime date) => '${date.month}/${date.day}'; + +/// Time caption of a timeline card, shared by every card style so a deadline, +/// exam and event describe their span with one set of wording. +/// +/// Narrow cards wrap on purpose: the two-line forms keep both endpoints +/// readable at the smallest zoom, unlike the single-line list caption in +/// `localizedAgendaTime`. +String agendaTimelineCaption(AppLocalizations l10n, AgendaEntry entry) { + if (entry.isDeadline) { + // An open-ended deadline has no due time, so it reports when it opened. + if (entry.startOnly) { + return l10n.agendaTimelineStarts(clockText(entry.startsAt)); + } + if (entry.hasAdjustedDeadline) { + return '${clockText(entry.dueAt)} ${l10n.agendaAdjustedDeadline}'; + } + return entry.effectiveAllDay + ? l10n.allDayUntil + : l10n.dueAt(clockText(entry.dueAt)); + } + final end = entry.endsAt!; + if (entry.allDay) { + final lastDay = end.subtract(const Duration(days: 1)); + return calendarDate(lastDay) == calendarDate(entry.startsAt) + ? l10n.allDay + : '${agendaTimelineShortDate(entry.startsAt)}–' + '${agendaTimelineShortDate(lastDay)}\n${l10n.allDay}'; + } + if (calendarDate(entry.startsAt) == calendarDate(end)) { + return '${clockText(entry.startsAt)}–${clockText(end)}'; + } + return '${agendaTimelineShortDate(entry.startsAt)} ' + '${clockText(entry.startsAt)}\n' + '→ ${agendaTimelineShortDate(end)} ${clockText(end)}'; +} diff --git a/lib/agenda/ui/timeline_entry.dart b/lib/agenda/ui/timeline_entry.dart index 6679dca..dffb8fc 100644 --- a/lib/agenda/ui/timeline_entry.dart +++ b/lib/agenda/ui/timeline_entry.dart @@ -7,6 +7,7 @@ import '../../l10n/formats.dart'; import '../../l10n/l10n.dart'; import '../model.dart'; import 'adjusted_deadline_painter.dart'; +import 'timeline_caption.dart'; import 'timeline_colors.dart'; /// Renders entry content independently of timeline coordinates and lane packing. @@ -89,7 +90,7 @@ class _AgendaTimelineEntryState extends State { MediaQuery.textScalerOf(context).scale(14) / 14, ); final date = entry.scheduledAt; - final caption = _caption(l10n, entry); + final caption = agendaTimelineCaption(l10n, entry); final overdueCaption = l10n.agendaReminderOverdue( entry.effectiveAllDay ? l10n.allDay : clockText(entry.dueAt), ); @@ -357,35 +358,6 @@ class _OverdueStatusBadge extends StatelessWidget { } } -String _day(DateTime date) => '${date.month}/${date.day}'; - -String _caption(AppLocalizations l10n, AgendaEntry entry) { - if (entry.isDeadline) { - // An open-ended deadline has no due time, so it reports when it opened. - if (entry.startOnly) { - return l10n.agendaTimelineStarts(clockText(entry.startsAt)); - } - if (entry.hasAdjustedDeadline) { - return '${clockText(entry.dueAt)} ${l10n.agendaAdjustedDeadline}'; - } - return entry.effectiveAllDay - ? l10n.allDayUntil - : l10n.dueAt(clockText(entry.dueAt)); - } - final end = entry.endsAt!; - if (entry.allDay) { - final lastDay = end.subtract(const Duration(days: 1)); - return calendarDate(lastDay) == calendarDate(entry.startsAt) - ? l10n.allDay - : '${_day(entry.startsAt)}–${_day(lastDay)}\n${l10n.allDay}'; - } - if (calendarDate(entry.startsAt) == calendarDate(end)) { - return '${clockText(entry.startsAt)}–${clockText(end)}'; - } - return '${_day(entry.startsAt)} ${clockText(entry.startsAt)}\n' - '→ ${_day(end)} ${clockText(end)}'; -} - String _fullDate(AppLocalizations l10n, DateTime date) => '${dateText(date)} ${localizedShortWeekday(l10n, date.weekday)}'; String _fullTime(AppLocalizations l10n, DateTime date) => diff --git a/lib/app/settings_controller.dart b/lib/app/settings_controller.dart index cec1d58..f014b9c 100644 --- a/lib/app/settings_controller.dart +++ b/lib/app/settings_controller.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../agenda/deadline_cutoff.dart'; +import '../agenda/model.dart'; import '../agenda/view_mode.dart'; import '../core/async/serial_queue.dart'; import '../core/diagnostics.dart'; @@ -47,7 +47,7 @@ class AppSettingsController extends ChangeNotifier { final agendaViewMode = ValueNotifier(AgendaViewMode.timeline); final timetableHeightMode = ValueNotifier(TimetableHeightMode.normal); final deadlineCutoff = ValueNotifier(const DeadlineCutoffWindow()); - final agendaLayoutDays = ValueNotifier(4); + final agendaLayoutDays = ValueNotifier(AgendaEntry.defaultTailDays); final agendaEventInfoBar = ValueNotifier(true); Future? _paletteSettingsLoad; @@ -170,8 +170,13 @@ class AppSettingsController extends ChangeNotifier { final saved = await _preferences.getString('agendaLayoutDays'); if (!_agendaLayoutDaysChosen && !_disposed && saved != null) { final days = int.tryParse(saved); - if (days == null || days < 1 || days > 30) { - throw const FormatException('DDL 排版宽度必须为 1–30 天'); + if (days == null || + days < AgendaEntry.minTailDays || + days > AgendaEntry.maxTailDays) { + throw FormatException( + 'DDL 排版宽度必须为 ${AgendaEntry.minTailDays}–' + '${AgendaEntry.maxTailDays} 天', + ); } agendaLayoutDays.value = days; _notify(); @@ -415,8 +420,13 @@ class AppSettingsController extends ChangeNotifier { Future setAgendaLayoutDays(int value) { if (_disposed) return Future.value(); - if (value < 1 || value > 30) { - throw RangeError.range(value, 1, 30, 'agendaLayoutDays'); + if (value < AgendaEntry.minTailDays || value > AgendaEntry.maxTailDays) { + throw RangeError.range( + value, + AgendaEntry.minTailDays, + AgendaEntry.maxTailDays, + 'agendaLayoutDays', + ); } _agendaLayoutDaysChosen = true; agendaLayoutDays.value = value; diff --git a/lib/app/ui/agenda_layout_days_settings.dart b/lib/app/ui/agenda_layout_days_settings.dart index 0ed313b..f1b0b28 100644 --- a/lib/app/ui/agenda_layout_days_settings.dart +++ b/lib/app/ui/agenda_layout_days_settings.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../../agenda/model.dart'; import '../../l10n/l10n.dart'; import '../../ui/app_page.dart'; import '../settings_controller.dart'; @@ -60,7 +61,9 @@ class _AgendaLayoutDaysScreenState extends State { Future _saveText(String text) async { final value = int.tryParse(text); - if (value == null || value < 1 || value > 30) { + if (value == null || + value < AgendaEntry.minTailDays || + value > AgendaEntry.maxTailDays) { setState(() => _invalid = true); return; } @@ -116,9 +119,9 @@ class _AgendaLayoutDaysScreenState extends State { label: l10n.settingsAgendaLayoutDays, child: Slider( key: const ValueKey('agenda-layout-days-slider'), - min: 1, - max: 30, - divisions: 29, + min: AgendaEntry.minTailDays.toDouble(), + max: AgendaEntry.maxTailDays.toDouble(), + divisions: AgendaEntry.maxTailDays - AgendaEntry.minTailDays, value: days.toDouble(), label: l10n.agendaTailDaysValue(days), semanticFormatterCallback: (value) => From 370b44fe556015a103e7ab155348e100e3f42715 Mon Sep 17 00:00:00 2001 From: Purple Rain <2891799786@qq.com> Date: Sat, 14 Nov 2026 16:12:05 +0800 Subject: [PATCH 24/24] fix(agenda): clear more-options headroom for floating labels The panel's children had no top padding, so the first outlined field's floating label was clipped at the expansion area's hard edge. Only event/exam editing hit it, where the location field leads the children. --- lib/agenda/ui/agenda_editor_sections.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/agenda/ui/agenda_editor_sections.dart b/lib/agenda/ui/agenda_editor_sections.dart index 75a507e..d4ac362 100644 --- a/lib/agenda/ui/agenda_editor_sections.dart +++ b/lib/agenda/ui/agenda_editor_sections.dart @@ -128,7 +128,7 @@ extension _AgendaEditorSections on _AgendaEditorState { initiallyExpanded: hasNotes || (_kind != AgendaKind.deadline && hasLocation), tilePadding: EdgeInsets.zero, - childrenPadding: const EdgeInsets.only(bottom: 8), + childrenPadding: const EdgeInsets.only(top: 12, bottom: 8), shape: const Border(), collapsedShape: const Border(), leading: const Icon(Icons.tune_rounded, size: 20),