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 d291c4d..fad05fb 100644 --- a/android/app/src/main/kotlin/dev/so1ve/bitree/WidgetAgenda.kt +++ b/android/app/src/main/kotlin/dev/so1ve/bitree/WidgetAgenda.kt @@ -71,8 +71,11 @@ internal data class WidgetContent( val nextUpdate: Long, ) -internal fun readWidgetContent(context: Context): WidgetContent { - val now = Calendar.getInstance(campusZone) +internal fun readWidgetContent( + context: Context, + nowMillis: Long = System.currentTimeMillis(), +): WidgetContent { + val now = Calendar.getInstance(campusZone).apply { timeInMillis = nowMillis } val today = dayStart(now.timeInMillis) val tomorrow = (today.clone() as Calendar).apply { add(Calendar.DATE, 1) } val evening = (today.clone() as Calendar).apply { set(Calendar.HOUR_OF_DAY, 21) } 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 13024f6..b44d9ac 100644 --- a/android/app/src/test/kotlin/dev/so1ve/bitree/WidgetAgendaTest.kt +++ b/android/app/src/test/kotlin/dev/so1ve/bitree/WidgetAgendaTest.kt @@ -5,6 +5,7 @@ import org.json.JSONArray import org.json.JSONObject import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -89,6 +90,57 @@ class WidgetAgendaTest { ) } + @Test + fun endedAgendaEntriesStayHiddenAndCoursesStillDriveNextUpdate() { + val now = timestamp("2026-11-09T12:00:00.000Z") + val courses = JSONArray() + .put(course("已下课", 1)) + .put(course("下午课", 6)) + val term = JSONObject() + .put("firstMonday", "2026-11-09") + .put("courses", courses) + File(context.filesDir, "schedule.json").writeText( + JSONObject().put("schemaVersion", 2).put("selectedTerm", "term") + .put("semesters", JSONObject().put("term", term)).toString(), + ) + agendaFile.writeText( + JSONObject().put("schemaVersion", 2) + .put("entries", JSONArray().put(timed("已结束日程", "event"))) + .toString(), + ) + + val content = readWidgetContent(context, now) + + 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): JSONObject = JSONObject() + .put("id", title) + .put("title", title) + .put("kind", kind) + .put("startsAt", "2026-11-09T09:00:00.000Z") + .put("endsAt", "2026-11-09T11:00:00.000Z") + .put("allDay", false) + .put("location", "") + .put("done", false) + .put("deleted", false) + + private fun course(name: String, section: Int): JSONObject = JSONObject() + .put("name", name) + .put("room", "") + .put("campus", "") + .put("weekday", 1) + .put("startSection", section) + .put("endSection", section) + .put("weeks", JSONArray().put(1)) + + private fun timestamp(value: String): Long = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT) + .apply { timeZone = TimeZone.getTimeZone("Asia/Shanghai") } + .parse(value)!!.time + private fun deadline(title: String): JSONObject { val zone = TimeZone.getTimeZone("Asia/Shanghai") val today = Calendar.getInstance(zone).apply { diff --git a/docs/architecture.md b/docs/architecture.md index 10cb227..d94354f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,22 +82,22 @@ USTC 风控采集失败时遵循学校登录页的协议:仅在风控请求出 日程页提供列表与横向多泳道时间轴,共用类型筛选,读取同一个 `AgendaController`,不混入课表课程。列表保留未完成/已完成分页;时间轴将两种状态同表排布,不按完成状态分配泳道,已完成或已结束条目仍沿用弱化样式。视图模式通过 `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 优先使用单条 DDL 的 `tailDays`;没有单独设置(`tailDays` 缺失或为 null)时跟随全局默认排版宽度,初始 4、范围 1~30 天。旧记录中已保存的单条长度保留优先级。编辑页“更多选项”提供拖尾滑条和“跟随全局设置”开关。普通日程和考试的信息区继续使用全局默认值。DDL 主体和日程左侧信息区按最小缩放 D−0.25 天对应的屏幕像素定宽,默认约 3.75 基线天,不采用 144px 独立正文或随缩放增长的日历天数拖尾。`paintStart/paintEnd` 表示当前实际绘制边界,真实时段和伪截止延伸段随缩放变化。纵向范围取全部筛选条目的最高泳道,不依赖横向可见条目。 `AgendaTimelineEntry` 只负责条目的外观和交互,不处理坐标或泳道。日程与考试在标题下常驻真实起止时间,跨天时分两行标出日期;全天日程显示日期范围。DDL 保留课程/来源,并另列截止时间。长按或鼠标悬停显示包含开始、结束或截止日期、课程及地点的分行预览;点按沿用详情抽屉。卡片最小高度为 56 逻辑像素,并随字体放大,确保完整时间与来源信息有独立空间。 -DDL 的右端为 `dueAt`,拖尾从左侧渐隐;只有开放时间的测验以开放时间为左端,拖尾向右延伸。拖尾按 `tailDays - 0.25` 个日历日计算,随时间轴缩放,并在同泳道相邻卡片处截短;整条宽度至少容纳正文。渐变区域不拦截点击,短日程的扩展点击区域也不会越过下一张卡片。全天 DDL 的右端沿用模型定义,为对应日期次日零点。 +DDL 主体右端为有效 `dueAt`,从左侧渐隐;仅有开放时间的项目从左向右淡出。主体屏幕定宽,不因邻卡裁短信息区。每日伪提前窗口支持关闭、跨日、两端包含及起止同点;原始截止不写回覆盖。命中窗口时,有效与真实截止之间绘制随时间缩放的宽斜纹矩形,真实标线使用变淡色并始终最后绘制,包括重叠情形。普通日程和考试真实时段为两条实线之间 45° 双色宽斜纹填充(实填充色与高亮色两带全不透明交替,首带为实色),左信息区使用实填充色与其半透明交替的斜纹及向内半线宽高亮色边框;设置中的“日程与考试信息栏”开关(默认开启)关闭时改用旧版方框卡片,在真实时段内显示标题与时间,并取消左侧信息区预留,占位改为 `[真实开始, 真实结束]`;DDL、仅开始项目不受影响。各区域完成透明度都乘原 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)。 +设置的「外观」二级页面提供明暗模式切换、浅色/深色调色板独立选择、JSON 文件导入、自定义配色的删除按钮及系统调色板开关,所有选项均可直接使用。关于弹窗中的五次连击可开启本次运行的开发者模式,设置页随后显示“开发者选项”;其中“将默认拖尾长度设为 null”按钮会将所有 4 天的 DDL 拖尾(含回收站)一次性改为跟随全局设置,其它长度保留,不自动迁移。解锁后弹窗保持打开并在版本号下方提示,避免多余的连击误触后面的设置项。导入成功后将完整配色内容复制到应用设置并应用到对应模式,不依赖原文件继续存在;校验或保存失败时保留原选择。删除正在使用的自定义配色会将对应模式恢复为默认。旧版本的 Cat 配色选择会自动恢复。Android 小组件接收浅色和深色课程的最终颜色,其系统课程配色开关仍独立控制;Android 12+ 的组件整体背景与文字继续使用系统颜色。文件格式与字段说明见 [调色板文件](palettes.md)。 -操作方式:拖动或普通滚轮横向浏览时间,双指捏合、`Ctrl + 滚轮` 或滑条连续缩放;上下拖动或 `Shift + 滚轮` 浏览泳道。时间轴隐藏显式与桌面自动添加的纵向滚动条,仍保留纵向滚动和惯性。单指或鼠标拖动释放后,使用 Flutter 的摩擦物理模拟继续滑行;缩放、新输入、隐藏视图或离开页面时停止惯性,触控板自身的惯性输入不额外叠加甩动。「今天」按钮回到今日居中。日程列表的完成状态以分段选择过滤,不使用横向分页;时间轴的横拖只用于时间导航。 +操作方式:拖动或普通滚轮横向浏览时间,双指捏合、`Ctrl + 滚轮` 或滑条连续缩放;上下拖动或 `Shift + 滚轮` 浏览泳道。时间轴隐藏显式与桌面自动添加的纵向滚动条,仍保留纵向滚动和惯性。纵向拖动通过 `ScrollPosition.drag/update/end` 使用 Flutter 的惯性和回弹,横向中心日期使用摩擦模拟;正常 overscroll 保留,筛选或尺寸缩小导致的旧偏移与卡片裁剪同步纠正。平移中心日期通过局部监听更新视口,网格文字和卡片内容层复用缓存,动态几何独立更新;缩放、新输入、隐藏视图或离开页面时停止惯性,触控板自身的惯性输入不额外叠加甩动。「今天」按钮回到今日居中。日程列表的完成状态以分段选择过滤,不使用横向分页;时间轴的横拖只用于时间导航。 ## 中国大学MOOC diff --git a/docs/deadline-cutoff.md b/docs/deadline-cutoff.md new file mode 100644 index 0000000..dbd1645 --- /dev/null +++ b/docs/deadline-cutoff.md @@ -0,0 +1,83 @@ +# DDL 每日伪截止时间段 + +## 时间规则 + +设置默认关闭,预置每日 22:00–次日 08:00。开关、开始和结束分钟作为一份 +JSON 设置保存;关闭仍保留所选时间。点击开始或结束时间后弹出与添加/编辑 +DDL 一致的原生 Material 时间选择器,复用 `showAppTimePicker`,默认 +表盘并支持原生键盘切换;页面无独立文字输入框。点击“确定”保存对应时间,取消 +不写入设置;两者都只关闭表盘并返回设置页。 + +区间两端包含:开始早于结束时匹配同一天;开始晚于结束时匹配跨日区间, +凌晨截止归到前一天的开始时间。开始与结束相同时仅匹配该时间点,不代表 +全天。设置精度是分钟,边界时刻为该分钟的 00 秒:结束时刻包含,但其后 +的秒和微秒不包含。 + +只对有真实截止时间的 DDL 生效;普通日程、考试、仅开始时间的测验不变。 +全天 DDL 的真实截止仍按原规则视为下一天 00:00,因此也可命中跨日窗口。 + +## 数据与显示 + +`AgendaEntry.startsAt`、`allDay` 及序列化内容保持原始数据。 +`realDueAt` 是原始截止;`dueAt` 是派生有效截止;`scheduledAt` 用于实际 +调整后的日期分组和排序。生效配置只作为运行时投影,不写回日程存储。 +关闭或更改设置总是从原始时间重算,不能累计伪提前,也不改变平台链接、 +提交状态或同步匹配。 + +命中窗口与实际提前分开表示:起点或单时间点命中时,即使没有提前,仍需 +处理两条标线的绘制顺序。详情中实际发生提前时同时说明有效和真实截止, +编辑器继续编辑原始时间。 + +## 泳道图 + +命中窗口的 DDL: + +- 排版宽度 `D` 优先使用单条 DDL 的 `tailDays`(1–30 天)。未单独设置时 + 跟随全局默认值(初始为 4 天);已有单条长度(包括 4 天)读取时保持原值。 + 编辑页“更多选项”可调整拖尾滑条,或恢复“跟随全局设置”。 + 开发者选项提供手动迁移按钮,将所有 4 天的 DDL 拖尾(含回收站)设为 + `null`,其它长度不变;不增加额外字段,不在读取时自动迁移。 +- 预留区间为 `[有效截止 − D 天, 真实截止]`。 +- 左侧主体为最小缩放 `D − 0.25` 天的屏幕定宽,不随缩放增长(默认 + 3.75 天);右边界位于有效截止。 +- 两条标线之间按真实时间间距绘制矩形,宽度跟随时间轴缩放。 +- 填充为屏幕定宽的 45° 宽斜纹,同一配色、交替透明度;完成后再衰减。 +- 真实标线未完成时使用原标线的 0.5 透明度,完成后再衰减为 0.25。 +- 真实标线在最上层绘制,包括零间距或小于标线宽度的情况;零间距没有 + 斜纹区域。有效标线及左侧主体保留正常完成/逾期规则。 +- 延伸段按视口裁剪,不能创建按时间跨度无限扩大的画布;全局泳道高度 + 规则保持不变。 + +## 日程与考试的泳道样式 + +普通日程和考试的左侧信息区使用全局默认 `D` 天预留及 `D − 0.25` 基线天 +的屏幕定宽,整体占位为 `[真实开始 − D 天, 真实结束]`。真实时段由开始、 +结束两条 3px 实线框住,其间是 45° 双色宽斜纹(实填充色与高亮色两带 +全不透明交替,自开始标线起首带为实色),按实际时距缩放;左侧同样是 +45° 斜纹(两带交替使用实填充色与其 0.5 半透明)及向内绘制的 1.5px +高亮色(accent)边框。两条时间线重合时结束线最后绘制。所有区域保持 +视口裁剪,日期与真实时距不因左侧信息区预留而改变。设置中的“日程与 +考试信息栏”开关(默认开启)可切换至旧版方框样式:文字显示在日程或 +考试方框内,取消左侧信息区预留,占位改为 `[真实开始, 真实结束]`。 +方框按真实时距缩放并裁剪到视口;DDL 与仅开始时间项目不受该开关影响。 + +## 与主线滚动/性能实现整合 + +泳道先按上述完整区间分配一次,缩放只更新绘制投影,不重新分配泳道。 +普通 DDL 和日程左信息区按各自生效宽度保持定宽,真实时间段才按缩放伸缩;不能照搬 +主线日历天数拖尾随缩放扩张或邻卡裁短信息区的规则。纵向范围仍取全局 +最高泳道,不依赖横向可见条目。 + +纵向拖动、惯性和回弹交给 Flutter ScrollPosition;正常 overscroll 可保留, +筛选/尺寸缩小导致的失效偏移则与裁剪同步纠正。平移只更新视口局部, +网格文字及卡片文字层可复用;双标线和斜纹的动态几何不能缓存为旧位置。 +自定义颜色由主线能力提供,完成透明度仍乘原 alpha。 + +## 验证 + +回归测试覆盖时间规则、精确边界、跨日/跨年、单时间点、原始存储不变、 +实时设置投影、关闭恢复、设置持久化/竞态/异常、中文英文窄屏和时间选择器, +以及排版区间、定宽主体、缩放延伸、裁剪、双标线重叠和完成透明度。 + +本地没有 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 ed444ed..a07ea5d 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'; @@ -15,29 +13,44 @@ import 'store.dart'; part 'merge.dart'; class AgendaController extends ChangeNotifier { - AgendaController(this._store); + AgendaController( + this._store, { + void Function(String, Object, StackTrace)? reportError, + }) : _reportError = reportError ?? AppDiagnostics.instance.report; final AgendaStore _store; + final void Function(String, Object, StackTrace) _reportError; List _entries = const []; final _operations = SerialQueue(); 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); + Iterable get deletedDeadlines => _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; @@ -48,7 +61,7 @@ class AgendaController extends ChangeNotifier { _entries = List.unmodifiable(await _store.readAndMigrate()); _ready = true; } on Exception catch (failure, stack) { - AppDiagnostics.instance.report('读取日程', failure, stack); + _reportError('读取日程', failure, stack); } finally { _busy = false; _notify(); @@ -59,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 @@ -204,6 +240,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; @@ -228,7 +265,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/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/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 b4c35ac..3124fdb 100644 --- a/lib/agenda/model.dart +++ b/lib/agenda/model.dart @@ -1,13 +1,17 @@ import '../core/json.dart'; import '../core/time.dart'; import 'agenda_color.dart'; +import 'deadline_cutoff.dart'; import 'deadline_source.dart'; import 'details.dart'; +export 'deadline_cutoff.dart'; export 'details.dart'; enum AgendaKind { event, deadline, exam } +const _copyUnset = Object(); + /// Times use the same Beijing wall-clock convention as campusNow(). class AgendaEntry { AgendaEntry({ @@ -28,8 +32,9 @@ class AgendaEntry { this.url, this.color, this.details, - this.tailDays = defaultTailDays, + this.tailDays, this.startOnly = false, + this.deadlineCutoffWindow = const DeadlineCutoffWindow(), }) { if (id.isEmpty || title.trim().isEmpty) { throw const FormatException('日程标题或编号缺失'); @@ -41,8 +46,9 @@ class AgendaEntry { (endsAt == null || !endsAt!.isAfter(startsAt))) { throw const FormatException('结束时间必须晚于开始时间'); } - if (tailDays < 1 || tailDays > 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('考试颜色由状态决定'); @@ -73,6 +79,7 @@ class AgendaEntry { } static const defaultTailDays = 4; + static const minTailDays = 1; static const maxTailDays = 30; final String id; @@ -93,9 +100,11 @@ class AgendaEntry { final AgendaColor? color; final AgendaEntryDetails? details; - /// Maximum gradient tail in calendar days. Other cards can shorten the tail; - /// it follows the timeline's time scale when zooming. - 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 @@ -104,11 +113,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); @@ -116,12 +151,17 @@ 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, + Object? tailDays = _copyUnset, + DeadlineCutoffWindow? deadlineCutoffWindow, + }) => AgendaEntry( id: id, title: title, kind: kind, @@ -139,8 +179,11 @@ class AgendaEntry { url: url, color: color, details: details, - tailDays: tailDays, + tailDays: identical(tailDays, _copyUnset) + ? this.tailDays + : tailDays as int?, startOnly: startOnly, + deadlineCutoffWindow: deadlineCutoffWindow ?? this.deadlineCutoffWindow, ); Map toJson() => { @@ -216,7 +259,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 1693192..87457b1 100644 --- a/lib/agenda/timeline_layout.dart +++ b/lib/agenda/timeline_layout.dart @@ -1,7 +1,6 @@ import 'model.dart'; -/// Displayed card and tail bounds for an entry in its assigned lane. -/// Event end times stay intact; layoutEnd includes their minimum display span. +/// Stable lane reservation plus the current zoom's actual painted bounds. class AgendaTimelinePlacement { const AgendaTimelinePlacement({ required this.entry, @@ -27,128 +26,123 @@ typedef _LaneReservation = ({ int lane, DateTime start, DateTime end, + DateTime layoutEnd, }); -/// Assign lanes once for a set of entries, then project them at any zoom. +/// Reserve the full feature intervals once; zoom only projects paint geometry. +/// In particular a fixed-width information card never changes an entry's lane. class AgendaTimelineLayout { AgendaTimelineLayout( Iterable entries, { required Duration minimumEventSpan, - required Duration deadlineCardSpan, - }) : _minimumEventSpan = minimumEventSpan, - _deadlineCardSpan = deadlineCardSpan, + required this.deadlineCardSpan, + int layoutDays = AgendaEntry.defaultTailDays, + this.reserveEventInformation = true, + }) : _layoutDays = layoutDays, _reservations = _reserveLanes( entries, minimumEventSpan: minimumEventSpan, - deadlineCardSpan: deadlineCardSpan, + layoutDays: layoutDays, + reserveEventInformation: reserveEventInformation, ); - final Duration _minimumEventSpan; - final Duration _deadlineCardSpan; + 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. + final bool reserveEventInformation; final List<_LaneReservation> _reservations; List atZoom(double zoom) { - final displayedEventSpan = Duration( - microseconds: (_minimumEventSpan.inMicroseconds / zoom).ceil(), - ); - final displayedCardSpan = Duration( - microseconds: (_deadlineCardSpan.inMicroseconds / zoom).ceil(), - ); - final paintedLaneEnds = {}; - final cards = - < - ({ - AgendaEntry entry, - int lane, - DateTime start, - DateTime end, - DateTime layoutEnd, - DateTime paintStart, - }) - >[]; - for (final interval in _reservations) { - final start = interval.entry.isDeadline && !interval.entry.startOnly - ? interval.end.subtract(displayedCardSpan) - : interval.start; - final end = interval.entry.startOnly - ? start.add(displayedCardSpan) - : interval.end; - final displayedMinimumEnd = start.add(displayedEventSpan); - final layoutEnd = - !interval.entry.isDeadline && end.isBefore(displayedMinimumEnd) - ? displayedMinimumEnd - : end; - final previousEnd = paintedLaneEnds[interval.lane]; - final tailStart = interval.entry.isDeadline && !interval.entry.startOnly - ? end.subtract( - const Duration(days: 1) * (interval.entry.tailDays - 0.25), - ) - : start; - final unobstructedStart = - previousEnd != null && previousEnd.isAfter(tailStart) - ? previousEnd - : tailStart; - cards.add(( - entry: interval.entry, - lane: interval.lane, - start: start, - end: end, - layoutEnd: layoutEnd, - paintStart: unobstructedStart.isBefore(start) - ? unobstructedStart - : start, - )); - paintedLaneEnds[interval.lane] = layoutEnd; + if (!zoom.isFinite || zoom < 1) { + throw RangeError.value(zoom, 'zoom', 'Must be finite and at least one'); } - final nextByLane = {}; - final result = []; - for (final card in cards.reversed) { - final tailEnd = card.entry.startOnly - ? card.start.add( - const Duration(days: 1) * (card.entry.tailDays - 0.25), - ) - : card.layoutEnd; - final nextStart = nextByLane[card.lane]; - final unobstructedEnd = nextStart != null && nextStart.isBefore(tailEnd) - ? nextStart - : tailEnd; - result.add( + Duration fixedWidthSpan(AgendaEntry entry) => Duration( + microseconds: + (deadlineCardSpan.inMicroseconds * + (entry.effectiveLayoutDays(_layoutDays) - .25) / + (_layoutDays - .25) / + zoom) + .ceil(), + ); + return [ + for (final interval in _reservations) AgendaTimelinePlacement( - entry: card.entry, - lane: card.lane, - start: card.start, - end: card.end, - layoutEnd: card.layoutEnd, - paintStart: card.paintStart, - paintEnd: unobstructedEnd.isAfter(card.layoutEnd) - ? unobstructedEnd - : card.layoutEnd, + entry: interval.entry, + lane: interval.lane, + start: interval.start, + end: interval.end, + layoutEnd: interval.layoutEnd, + paintStart: + interval.entry.startOnly || + (!interval.entry.isDeadline && !reserveEventInformation) + ? interval.entry.startsAt + : (interval.entry.isDeadline + ? interval.entry.dueAt + : interval.entry.startsAt) + .subtract(fixedWidthSpan(interval.entry)), + paintEnd: interval.entry.startOnly + ? interval.entry.startsAt.add(fixedWidthSpan(interval.entry)) + : interval.end, ), - ); - nextByLane[card.lane] = card.start; - } - return result.reversed.toList(growable: false); + ]; } } +/// Compatibility entry point for pure layout consumers. Reservations keep their +/// real times; callers displaying a viewport should retain AgendaTimelineLayout +/// and use atZoom rather than reassigning lanes for every zoom value. +List layoutAgendaTimeline( + Iterable entries, { + required Duration minimumEventSpan, + int layoutDays = AgendaEntry.defaultTailDays, + bool reserveEventInformation = true, +}) => AgendaTimelineLayout( + entries, + minimumEventSpan: minimumEventSpan, + deadlineCardSpan: Duration( + microseconds: (Duration.microsecondsPerDay * (layoutDays - .25)).round(), + ), + layoutDays: layoutDays, + reserveEventInformation: reserveEventInformation, +).atZoom(1); + List<_LaneReservation> _reserveLanes( Iterable entries, { required Duration minimumEventSpan, - required Duration deadlineCardSpan, + required int layoutDays, + required bool reserveEventInformation, }) { + if (layoutDays < AgendaEntry.minTailDays || + layoutDays > AgendaEntry.maxTailDays) { + throw RangeError.range( + layoutDays, + AgendaEntry.minTailDays, + AgendaEntry.maxTailDays, + 'layoutDays', + ); + } + DateTime reservationStart(AgendaEntry entry) => + entry.startOnly || (!entry.isDeadline && !reserveEventInformation) + ? entry.startsAt + : (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: entry.effectiveLayoutDays(layoutDays)), + ) + : entry.realDueAt + : entry.endsAt!; final intervals = [ for (final entry in entries.where((entry) => !entry.deleted)) ( entry: entry, - start: entry.isDeadline && !entry.startOnly - ? entry.dueAt.subtract(deadlineCardSpan) - : entry.startsAt, - end: entry.isDeadline - ? entry.startOnly - ? entry.startsAt.add(deadlineCardSpan) - : entry.dueAt - : entry.endsAt!, + start: reservationStart(entry), + end: reservationEnd(entry), ), ]..sort((a, b) { final byStart = a.start.compareTo(b.start); @@ -157,28 +151,27 @@ List<_LaneReservation> _reserveLanes( return byEnd != 0 ? byEnd : a.entry.id.compareTo(b.entry.id); }); final laneEnds = []; - final cards = <_LaneReservation>[]; + final reservations = <_LaneReservation>[]; for (final interval in intervals) { - final minimumEnd = interval.start.add(minimumEventSpan); - final reservationEnd = + final minimumEnd = interval.entry.startsAt.add(minimumEventSpan); + final layoutEnd = !interval.entry.isDeadline && interval.end.isBefore(minimumEnd) ? minimumEnd : interval.end; var lane = laneEnds.indexWhere((end) => !interval.start.isBefore(end)); if (lane < 0) { lane = laneEnds.length; - } - if (lane == laneEnds.length) { - laneEnds.add(reservationEnd); + laneEnds.add(layoutEnd); } else { - laneEnds[lane] = reservationEnd; + laneEnds[lane] = layoutEnd; } - cards.add(( + reservations.add(( entry: interval.entry, lane: lane, start: interval.start, end: interval.end, + layoutEnd: layoutEnd, )); } - return cards; + return reservations; } diff --git a/lib/agenda/ui/adjusted_deadline_painter.dart b/lib/agenda/ui/adjusted_deadline_painter.dart new file mode 100644 index 0000000..303b058 --- /dev/null +++ b/lib/agenda/ui/adjusted_deadline_painter.dart @@ -0,0 +1,177 @@ +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.secondColor, + this.stripeWidth = 16, + this.highOpacity = 0.32, + this.lowOpacity = 0.16, + }); + + 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; + + @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(); + final second = secondColor; + 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(); + } + + @override + bool shouldRepaint(AdjustedDeadlineExtensionPainter oldDelegate) => + oldDelegate.effectiveX != effectiveX || + oldDelegate.realX != realX || + oldDelegate.color != color || + oldDelegate.secondColor != secondColor || + oldDelegate.stripeWidth != stripeWidth || + oldDelegate.highOpacity != highOpacity || + oldDelegate.lowOpacity != lowOpacity; +} + +/// 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 aee683d..937645c 100644 --- a/lib/agenda/ui/agenda_detail.dart +++ b/lib/agenda/ui/agenda_detail.dart @@ -224,7 +224,9 @@ Future showAgendaEntry( size: 20, ), label: Text( - current.done ? l10n.agendaMarkUndone : l10n.agendaMarkDone, + current.done + ? l10n.agendaMarkUndone + : l10n.agendaMarkDone, ), ), ) @@ -252,6 +254,8 @@ 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; final accent = entry.overdue(now) ? null : agendaTimelineColors(context, entry, now).accent; @@ -261,18 +265,23 @@ class _EntryTime extends StatelessWidget { 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( @@ -300,21 +309,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_editor.dart b/lib/agenda/ui/agenda_editor.dart index 51be736..a59cd6d 100644 --- a/lib/agenda/ui/agenda_editor.dart +++ b/lib/agenda/ui/agenda_editor.dart @@ -75,7 +75,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); - late 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 = @@ -220,12 +220,12 @@ class _AgendaEditorState extends State<_AgendaEditor> { } } - void _updateColor(AgendaColor? color) { - setState(() => _color = color); + void _updateTailDays(int? days) { + setState(() => _tailDays = days); } - void _updateTailDays(int value) { - setState(() => _tailDays = value); + void _updateColor(AgendaColor? color) { + setState(() => _color = color); } @override @@ -349,7 +349,7 @@ class _AgendaEditorState extends State<_AgendaEditor> { ), const SizedBox(height: 8), if (_kind != AgendaKind.exam) _colorSelector(), - _moreOptions(theme), + _moreOptions(), ], ), ); diff --git a/lib/agenda/ui/agenda_editor_sections.dart b/lib/agenda/ui/agenda_editor_sections.dart index b5be4d2..d4ac362 100644 --- a/lib/agenda/ui/agenda_editor_sections.dart +++ b/lib/agenda/ui/agenda_editor_sections.dart @@ -119,57 +119,53 @@ extension _AgendaEditorSections on _AgendaEditorState { ); } - Widget _moreOptions(ThemeData theme) { + Widget _moreOptions() { final l10n = context.l10n; final hasLocation = _location.text.trim().isNotEmpty; final hasNotes = _notes.text.trim().isNotEmpty; - final hasCustomTail = - _kind == AgendaKind.deadline && - _tailDays != AgendaEntry.defaultTailDays; return ExpansionTile( key: const ValueKey('agenda-more-options'), initiallyExpanded: - hasNotes || - hasCustomTail || - (_kind != AgendaKind.deadline && hasLocation), + 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), title: Text(l10n.agendaMoreOptions), children: [ 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) => _updateTailDays(value.round()), - ), + 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, + ), ), - Align( - alignment: AlignmentDirectional.centerStart, - child: Text( - l10n.agendaTailDaysHint, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + if (_tailDays case final days?) ...[ + Semantics( + label: l10n.agendaTailDays, + child: Slider( + key: const ValueKey('agenda-tail-days-slider'), + value: days.toDouble(), + min: AgendaEntry.minTailDays.toDouble(), + max: AgendaEntry.maxTailDays.toDouble(), + divisions: AgendaEntry.maxTailDays - AgendaEntry.minTailDays, + 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) ...[ diff --git a/lib/agenda/ui/agenda_list_tile.dart b/lib/agenda/ui/agenda_list_tile.dart index 0358e45..b408516 100644 --- a/lib/agenda/ui/agenda_list_tile.dart +++ b/lib/agenda/ui/agenda_list_tile.dart @@ -46,6 +46,10 @@ 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; @@ -55,7 +59,7 @@ class AgendaListTile extends StatelessWidget { 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 e64a798..59f1bc0 100644 --- a/lib/agenda/ui/agenda_timeline.dart +++ b/lib/agenda/ui/agenda_timeline.dart @@ -5,13 +5,16 @@ import 'package:flutter/material.dart'; import 'package:flutter/physics.dart'; import 'package:flutter/services.dart'; +import '../../app/controller.dart'; import '../../core/time.dart'; import '../../l10n/formats.dart'; import '../../l10n/l10n.dart'; -import '../controller.dart'; import '../model.dart'; 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'; @@ -19,6 +22,37 @@ part 'agenda_timeline_painters.dart'; const _microsecondsPerHour = Duration.microsecondsPerSecond * 3600; const _minimumEventWidth = 2.0; +/// Keep genuine user overscroll, but retire an in-range position immediately +/// when filtering or resizing makes its old content range invalid. +class _TimelineScrollPhysics extends BouncingScrollPhysics { + const _TimelineScrollPhysics({super.parent}); + + @override + _TimelineScrollPhysics applyTo(ScrollPhysics? ancestor) => + _TimelineScrollPhysics(parent: buildParent(ancestor)); + + @override + double adjustPositionForNewDimensions({ + required ScrollMetrics oldPosition, + required ScrollMetrics newPosition, + required bool isScrolling, + required double velocity, + }) { + if (newPosition.maxScrollExtent < oldPosition.maxScrollExtent && + !oldPosition.outOfRange) { + return newPosition.pixels + .clamp(newPosition.minScrollExtent, newPosition.maxScrollExtent) + .toDouble(); + } + return super.adjustPositionForNewDimensions( + oldPosition: oldPosition, + newPosition: newPosition, + isScrolling: isScrolling, + velocity: velocity, + ); + } +} + /// A viewport-sized timeline: panning changes a date, not a giant scroll offset. class AgendaTimeline extends StatefulWidget { const AgendaTimeline({ @@ -31,7 +65,7 @@ class AgendaTimeline extends StatefulWidget { this.visible = true, }); - final AgendaController controller; + final AppController controller; final List entries; final DateTime now; @@ -54,8 +88,8 @@ class _AgendaTimelineState extends State late final AnimationController _horizontalFling; Drag? _verticalDrag; ScrollHoldController? _verticalHold; - double? _retainedLaneExtent; - double? _laneViewportHeight; + double? _lastContentExtent; + double? _lastViewportHeight; double _lastHorizontal = 0; double _flingPixelsPerHour = 1; bool _gestureHadMultiplePointers = false; @@ -70,13 +104,16 @@ class _AgendaTimelineState extends State int _gesturePointers = 0; DateTime? _gestureAnchor; List? _cachedEntries; + DeadlineCutoffWindow? _cachedCutoff; + int? _cachedLayoutDays; + bool? _cachedEventInfoBar; Duration? _cachedMinimumEventSpan; Duration? _cachedDeadlineCardSpan; double? _cachedZoom; AgendaTimelineLayout? _laneLayout; List _cachedPlacements = const []; - final Map _nextStartById = {}; - final Map _cards = {}; + final Map _plainCards = {}; + _TimelineText? _gridText; late double _fontScale; late double _headerHeight; @@ -86,30 +123,34 @@ class _AgendaTimelineState extends State Duration minimumEventSpan, Duration deadlineCardSpan, ) { + 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; - _cards.clear(); + _plainCards.clear(); _laneLayout = AgendaTimelineLayout( - widget.entries, + widget.entries.map((entry) => entry.withDeadlineCutoff(cutoff)), minimumEventSpan: minimumEventSpan, deadlineCardSpan: deadlineCardSpan, + layoutDays: layoutDays, + reserveEventInformation: eventInfoBar, ); } if (_cachedZoom != _zoom) { _cachedZoom = _zoom; _cachedPlacements = _laneLayout!.atZoom(_zoom); - _nextStartById.clear(); - final nextByLane = {}; - for (final placement in _cachedPlacements.reversed) { - final next = nextByLane[placement.lane]; - if (next != null) _nextStartById[placement.entry.id] = next; - nextByLane[placement.lane] = placement.start; - } } return _cachedPlacements; } @@ -120,6 +161,9 @@ class _AgendaTimelineState extends State _visibleCenter = ValueNotifier( calendarDate(widget.now).add(const Duration(hours: 12)), ); + 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); } @@ -218,8 +262,25 @@ class _AgendaTimelineState extends State @override 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, + ); + 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) { - _retainedLaneExtent = null; + _cachedEntries = null; } if (oldWidget.visible != widget.visible) { _stopMotion(holdVertical: true); @@ -245,6 +306,12 @@ class _AgendaTimelineState extends State } } + void _settingsChanged() { + if (!mounted) return; + _stopMotion(); + setState(() => _cachedEntries = null); + } + @override void deactivate() { _stopMotion(holdVertical: true); @@ -257,6 +324,13 @@ class _AgendaTimelineState extends State @override void dispose() { _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(); @@ -308,7 +382,9 @@ class _AgendaTimelineState extends State : _zoom == 24 ? l10n.agendaTimelineOneHour : l10n.agendaTimelineHours(hoursPerCell.toStringAsFixed(1)); - final deadlineCardWidth = 144.0 * _fontScale; + final deadlineCardWidth = + cellWidth * + (widget.controller.settings.agendaLayoutDays.value - .25); final deadlineCardSpan = Duration( microseconds: (deadlineCardWidth / baseline * _microsecondsPerHour) .ceil(), @@ -574,12 +650,14 @@ class _AgendaTimelineState extends State final left = _timeAt(0, width, pixelsPerHour); final right = _timeAt(width, width, pixelsPerHour); final placementsInTime = []; - var displayedLaneCount = 0; + final laneCount = placements.fold( + 0, + (count, placement) => math.max(count, placement.lane + 1), + ); for (final placement in placements) { if (placement.paintEnd.isAfter(left) && placement.paintStart.isBefore(right)) { placementsInTime.add(placement); - displayedLaneCount = math.max(displayedLaneCount, placement.lane + 1); } } final painter = _TimelineGridPainter( @@ -612,32 +690,24 @@ class _AgendaTimelineState extends State builder: (context, _) => LayoutBuilder( builder: (context, laneConstraints) { final height = laneConstraints.maxHeight; - if (_laneViewportHeight != height) { - _laneViewportHeight = height; - _retainedLaneExtent = null; - } + final contentHeight = math.max( + height, + laneCount * laneHeight + 12, + ); + final extent = contentHeight - height; final previousOffset = _lanes.hasClients ? _lanes.offset : 0.0; - final contentExtent = math.max( - 0.0, - (displayedLaneCount + 1) * laneHeight + 12 - height, - ); - // Keep an old lower position when the date changes. - // Scrolling up releases it, leaving one spare lane. - _retainedLaneExtent = math.max( - contentExtent, - math.min( - _retainedLaneExtent ?? contentExtent, - math.max(0.0, previousOffset), - ), - ); - final contentHeight = height + _retainedLaneExtent!; - // Filtering and resizing may clamp the offset - // in this frame. Cull at that new position. - final offset = previousOffset - .clamp(0.0, _retainedLaneExtent!) - .toDouble(); + // Bounce overscroll belongs to ScrollPosition. Only a changed + // range/viewport invalidates a previously valid culling offset. + final dimensionsChanged = + _lastContentExtent != extent || + _lastViewportHeight != height; + _lastContentExtent = extent; + _lastViewportHeight = height; + final offset = dimensionsChanged + ? previousOffset.clamp(0.0, extent).toDouble() + : previousOffset; final visible = placementsInTime.where( (placement) => (placement.lane + 1) * laneHeight >= offset && @@ -666,7 +736,7 @@ class _AgendaTimelineState extends State child: CustomScrollView( key: const ValueKey('agenda-timeline-lanes'), controller: _lanes, - physics: const BouncingScrollPhysics( + physics: const _TimelineScrollPhysics( parent: NeverScrollableScrollPhysics(), ), slivers: [ @@ -726,51 +796,105 @@ class _AgendaTimelineState extends State double laneHeight, ) { final entry = placement.entry; + final cellWidth = (width / 5).clamp(80.0, 140.0).toDouble() * .6; + final deadlineWidth = + cellWidth * + (entry.effectiveLayoutDays( + widget.controller.settings.agendaLayoutDays.value, + ) - + .25); double x(DateTime time) => width / 2 + time.difference(_center).inMicroseconds / _microsecondsPerHour * pixelsPerHour; - final actualRight = x(placement.paintEnd); - final actualLeft = x(placement.paintStart); + if (!entry.isDeadline && + widget.controller.settings.agendaEventInfoBar.value) { + 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.agenda, entry), + ), + ); + } + 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.agenda, 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 + ? x(placement.start) + deadlineWidth + : x(placement.layoutEnd); + final actualLeft = entry.isDeadline + ? actualRight - deadlineWidth + : 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 right = entry.isDeadline ? actualRight : math.min(width, actualRight); - double spanWidth(DateTime start, DateTime end) => - end.difference(start).inMicroseconds / - _microsecondsPerHour * - pixelsPerHour; final barWidth = entry.isDeadline - ? spanWidth(placement.paintStart, placement.paintEnd) - : math.max(0.0, right - left); - final cardWidth = entry.isDeadline ? 144.0 * _fontScale : barWidth; - final nextStart = _nextStartById[entry.id]; - final availableWidth = nextStart == null - ? double.infinity - : math.max( - 0.0, - entry.isDeadline - ? spanWidth(placement.paintStart, nextStart) - : x(nextStart) - left, - ); - final tapWidth = math.min(math.max(24.0, barWidth), availableWidth); - var card = _cards[entry.id]; + ? 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 || card.entry != entry || card.now != widget.now || card.barWidth != barWidth || - card.cardWidth != cardWidth) { + card.deadlineWidth != deadlineWidth) { card = AgendaTimelineEntry( entry: entry, now: widget.now, barWidth: barWidth, - cardWidth: cardWidth, - onTap: () => showAgendaEntry(context, widget.controller, entry), + deadlineWidth: deadlineWidth, + onTap: () => showAgendaEntry(context, widget.controller.agenda, entry), ); - _cards[entry.id] = card; + _plainCards[entry.id] = card; } return Positioned( - key: ValueKey(entry.id), + key: ValueKey(('agenda-timeline-entry', entry.id)), left: left, top: placement.lane * laneHeight + 12, width: tapWidth, 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/calendar_list.dart b/lib/agenda/ui/calendar_list.dart index 1016170..cfd8261 100644 --- a/lib/agenda/ui/calendar_list.dart +++ b/lib/agenda/ui/calendar_list.dart @@ -39,7 +39,7 @@ Future showAgendaDay( controller.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 3b43e26..827dcde 100644 --- a/lib/agenda/ui/calendar_screen.dart +++ b/lib/agenda/ui/calendar_screen.dart @@ -196,7 +196,7 @@ class _CalendarScreenState extends State { loadStatus(), Expanded( child: AgendaTimeline( - controller: controller.agenda, + controller: controller, entries: entries, now: now, filter: (kind: _kind, term: selectedTerm), diff --git a/lib/agenda/ui/calendar_screen_components.dart b/lib/agenda/ui/calendar_screen_components.dart index 5d61b0e..a4dc736 100644 --- a/lib/agenda/ui/calendar_screen_components.dart +++ b/lib/agenda/ui/calendar_screen_components.dart @@ -86,7 +86,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; }); @@ -94,7 +94,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 c9cece2..8428100 100644 --- a/lib/agenda/ui/deleted_deadlines.dart +++ b/lib/agenda/ui/deleted_deadlines.dart @@ -144,7 +144,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(); @@ -158,7 +158,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/event_timeline_entry.dart b/lib/agenda/ui/event_timeline_entry.dart new file mode 100644 index 0000000..3ad564b --- /dev/null +++ b/lib/agenda/ui/event_timeline_entry.dart @@ -0,0 +1,266 @@ +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_caption.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; +} + +/// 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 { + const EventTimelinePainter({ + required this.geometry, + required this.fill, + required this.accent, + this.lineWidth = eventTimelineLineWidth, + 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) { + if (size.isEmpty) return; + canvas.save(); + canvas.clipRect(Offset.zero & size); + final information = Rect.fromLTRB( + geometry.informationLeft, 0, geometry.startX, size.height, + ); + 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, + ); + } + } + // 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), + 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 || + oldDelegate.showInformation != showInformation; +} + +class EventTimelineEntry extends StatefulWidget { + const EventTimelineEntry({ + super.key, + required this.entry, + required this.now, + required this.geometry, + required this.informationWidth, + required this.onTap, + this.showInformation = true, + }); + + final AgendaEntry entry; + final DateTime now; + final EventTimelineGeometry geometry; + 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(); +} + +class _EventTimelineEntryState extends State { + Widget? _information; + + AgendaEntry get entry => widget.entry; + DateTime get now => widget.now; + EventTimelineGeometry get geometry => widget.geometry; + double get informationWidth => widget.informationWidth; + void _open() => widget.onTap(); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _information = null; + } + + @override + void didUpdateWidget(covariant EventTimelineEntry oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.entry != widget.entry || + oldWidget.now != widget.now || + oldWidget.informationWidth != widget.informationWidth) { + _information = null; + } + } + + @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 span = agendaTimelineCaption(l10n, entry); + final caption = overdue + ? l10n.agendaReminderOverdue(clockText(entry.endsAt!)) + : span; + final semantics = l10n.agendaTimelineSemanticsEntry( + entry.kind.localizedLabel(l10n), + title, + '${localizedAgendaDateTime(l10n, entry.startsAt)} ' + '${span.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: _open, + child: ExcludeSemantics( + child: Tooltip( + message: preview, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _open, + child: ClipRect( + child: CustomPaint( + painter: EventTimelinePainter( + geometry: geometry, + fill: colors.fill, + accent: colors.accent, + showInformation: widget.showInformation, + ), + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + 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(), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} 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_colors.dart b/lib/agenda/ui/timeline_colors.dart index 0c6e9ce..947b2f2 100644 --- a/lib/agenda/ui/timeline_colors.dart +++ b/lib/agenda/ui/timeline_colors.dart @@ -102,30 +102,35 @@ 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 (customColors != null) { colors = ( - fill: customColors.fill.withValues(alpha: fillAlpha), - text: customColors.text.withValues(alpha: foregroundAlpha), - accent: customColors.accent.withValues(alpha: foregroundAlpha), + fill: customColors.fill.withValues(alpha: customColors.fill.a * fillAlpha), + text: customColors.text.withValues( + alpha: customColors.text.a * foregroundAlpha, + ), + accent: customColors.accent.withValues( + alpha: customColors.accent.a * foregroundAlpha, + ), ); } else 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 = @@ -140,12 +145,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/lib/agenda/ui/timeline_entry.dart b/lib/agenda/ui/timeline_entry.dart index 3762910..dffb8fc 100644 --- a/lib/agenda/ui/timeline_entry.dart +++ b/lib/agenda/ui/timeline_entry.dart @@ -6,6 +6,8 @@ import '../../core/time.dart'; 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. @@ -15,27 +17,35 @@ class AgendaTimelineEntry extends StatefulWidget { required this.entry, required this.now, required this.barWidth, - required this.cardWidth, + required this.deadlineWidth, required this.onTap, + this.adjustedGeometry, }); final AgendaEntry entry; final DateTime now; final double barWidth; - final double cardWidth; + final double deadlineWidth; final VoidCallback onTap; + final AdjustedDeadlineGeometry? adjustedGeometry; @override State createState() => _AgendaTimelineEntryState(); } class _AgendaTimelineEntryState extends State { - Widget? _card; + Widget? _body; + + AgendaEntry get entry => widget.entry; + DateTime get now => widget.now; + double get barWidth => widget.barWidth; + double get deadlineWidth => widget.deadlineWidth; + void _open() => widget.onTap(); @override void didChangeDependencies() { super.didChangeDependencies(); - _card = null; + _body = null; } @override @@ -43,19 +53,34 @@ class _AgendaTimelineEntryState extends State { super.didUpdateWidget(oldWidget); if (oldWidget.entry != widget.entry || oldWidget.now != widget.now || - (widget.entry.isDeadline - ? oldWidget.cardWidth != widget.cardWidth - : oldWidget.barWidth != widget.barWidth)) { - _card = null; + oldWidget.barWidth != widget.barWidth || + oldWidget.deadlineWidth != widget.deadlineWidth) { + _body = null; } } - void _open() => widget.onTap(); + @override + Widget build(BuildContext context) { + // Geometry belongs to the fresh outer wrapper, never the cached text layer. + final body = _body ??= RepaintBoundary(child: _buildBody(context)); + final geometry = widget.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: _open, + semanticsLabel: _preview(context.l10n, entry, now), + ); + } - Widget _buildCard(BuildContext context) { - final entry = widget.entry; - final now = widget.now; - final width = entry.isDeadline ? widget.cardWidth : widget.barWidth; + Widget _buildBody(BuildContext context) { final l10n = context.l10n; final scheme = Theme.of(context).colorScheme; final colors = agendaTimelineColors(context, entry, now); @@ -64,17 +89,19 @@ class _AgendaTimelineEntryState extends State { 1.0, MediaQuery.textScalerOf(context).scale(14) / 14, ); - final date = entry.startsAt; - final caption = _caption(l10n, entry); + final date = entry.scheduledAt; + final caption = agendaTimelineCaption(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; final deadlineFill = overdue ? warningBackground : colors.fill; final contentColor = overdue ? warningForeground : colors.text; final titleText = localizedAgendaTitle(l10n, entry); - // Open-ended cards start at their left edge; their tails extend right. + // An open-ended deadline mirrors the ordinary deadline card: the accent + // line becomes its left edge, the fill fades out towards the right, and + // the text hugs the line on the left instead of the right. final openEnded = entry.isDeadline && entry.startOnly; final accentEdge = BorderSide( color: overdue ? scheme.error : colors.accent, @@ -114,7 +141,7 @@ class _AgendaTimelineEntryState extends State { ), ); - final card = Semantics( + return Semantics( button: true, label: description, onTap: _open, @@ -128,17 +155,30 @@ class _AgendaTimelineEntryState extends State { child: Align( alignment: Alignment.centerLeft, child: SizedBox( - key: ValueKey(( - entry.isDeadline - ? 'agenda-timeline-card' - : 'agenda-timeline-bar', - entry.id, - )), - width: width, + key: ValueKey(('agenda-timeline-bar', entry.id)), + width: barWidth, height: double.infinity, child: Ink( decoration: BoxDecoration( - color: entry.isDeadline ? deadlineFill : colors.fill, + color: entry.isDeadline ? null : colors.fill, + gradient: entry.isDeadline + ? LinearGradient( + colors: openEnded + ? [ + deadlineFill, + deadlineFill, + deadlineFill.withValues(alpha: 0), + ] + : [ + deadlineFill.withValues(alpha: 0), + deadlineFill, + deadlineFill, + ], + stops: openEnded + ? const [0, 0.82, 1] + : const [0, 0.18, 1], + ) + : null, borderRadius: radius, ), // Ink includes border widths in its padding. Paint the @@ -175,11 +215,15 @@ class _AgendaTimelineEntryState extends State { ), ), Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, + padding: EdgeInsets.only( + left: entry.isDeadline && !openEnded + ? deadlineWidth * 0.37 + : 8, + right: openEnded ? deadlineWidth * 0.37 : 8, + top: 4, + bottom: 4, ), - child: width < 48 + child: barWidth < 48 ? const SizedBox.shrink() : Column( mainAxisAlignment: MainAxisAlignment.center, @@ -242,8 +286,8 @@ class _AgendaTimelineEntryState extends State { caption, maxLines: entry.isDeadline ? 1 : 2, overflow: TextOverflow.ellipsis, - textAlign: - entry.isDeadline && !openEnded + textAlign: entry.isDeadline && + !openEnded ? TextAlign.right : TextAlign.left, style: TextStyle( @@ -270,57 +314,6 @@ class _AgendaTimelineEntryState extends State { ), ), ); - // Cache the card layer independently of the tail, which the viewport clips. - return RepaintBoundary(child: card); - } - - @override - Widget build(BuildContext context) { - final card = _card ??= _buildCard(context); - final entry = widget.entry; - if (!entry.isDeadline) return card; - final barWidth = widget.barWidth; - final cardWidth = widget.cardWidth; - final openEnded = entry.startOnly; - final deadlineFill = entry.overdue(widget.now) - ? Theme.of(context).colorScheme.errorContainer - : agendaTimelineColors(context, entry, widget.now).fill; - return SizedBox( - key: ValueKey(('agenda-timeline-bar', entry.id)), - width: barWidth, - height: double.infinity, - child: Stack( - fit: StackFit.expand, - children: [ - if (barWidth > cardWidth) - Positioned( - left: openEnded ? cardWidth : 0, - right: openEnded ? 0 : cardWidth, - top: 0, - bottom: 0, - child: IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: openEnded - ? [deadlineFill, deadlineFill.withValues(alpha: 0)] - : [deadlineFill.withValues(alpha: 0), deadlineFill], - ), - ), - ), - ), - ), - Positioned( - left: openEnded ? 0 : null, - right: openEnded ? null : 0, - top: 0, - bottom: 0, - width: cardWidth, - child: card, - ), - ], - ), - ); } } @@ -365,30 +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)); - } - return entry.allDay ? 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) => @@ -397,6 +366,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 @@ -414,8 +389,7 @@ String _previewDeadlineLine(AppLocalizations l10n, AgendaEntry entry) { String _preview(AppLocalizations l10n, AgendaEntry entry, DateTime now) => [ '${entry.kind.localizedLabel(l10n)} · ${localizedAgendaTitle(l10n, entry)}', - if (entry.isDeadline) - _previewDeadlineLine(l10n, entry) + if (entry.isDeadline) _previewDeadlineLine(l10n, entry) else if (entry.allDay) ...[ l10n.agendaTimelineStarts( '${_fullDate(l10n, entry.startsAt)} ${l10n.allDay}', diff --git a/lib/app/controller.dart b/lib/app/controller.dart index 50837b6..719cc63 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(); classroomRepository.dispose(); diff --git a/lib/app/settings_controller.dart b/lib/app/settings_controller.dart index afdf364..f014b9c 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/model.dart'; import '../agenda/view_mode.dart'; import '../core/async/serial_queue.dart'; import '../core/diagnostics.dart'; @@ -45,6 +46,9 @@ class AppSettingsController extends ChangeNotifier { final SystemPaletteSource _systemPaletteSource; final agendaViewMode = ValueNotifier(AgendaViewMode.timeline); final timetableHeightMode = ValueNotifier(TimetableHeightMode.normal); + final deadlineCutoff = ValueNotifier(const DeadlineCutoffWindow()); + final agendaLayoutDays = ValueNotifier(AgendaEntry.defaultTailDays); + final agendaEventInfoBar = ValueNotifier(true); Future? _paletteSettingsLoad; SystemPalette? _systemPalette; @@ -53,6 +57,9 @@ class AppSettingsController extends ChangeNotifier { bool _themeChosen = false; bool _agendaViewChosen = false; bool _timetableHeightChosen = false; + bool _deadlineCutoffChosen = false; + bool _agendaLayoutDaysChosen = false; + bool _agendaEventInfoBarChosen = false; bool _developerMode = false; bool _widgetUseSystemPalette = false; bool _globalUseSystemPalette = false; @@ -146,6 +153,49 @@ 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); + } + try { + final saved = await _preferences.getString('agendaLayoutDays'); + if (!_agendaLayoutDaysChosen && !_disposed && saved != null) { + final days = int.tryParse(saved); + if (days == null || + days < AgendaEntry.minTailDays || + days > AgendaEntry.maxTailDays) { + throw FormatException( + 'DDL 排版宽度必须为 ${AgendaEntry.minTailDays}–' + '${AgendaEntry.maxTailDays} 天', + ); + } + agendaLayoutDays.value = days; + _notify(); + } + } 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) { @@ -356,6 +406,50 @@ 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 setAgendaLayoutDays(int value) { + if (_disposed) return Future.value(); + if (value < AgendaEntry.minTailDays || value > AgendaEntry.maxTailDays) { + throw RangeError.range( + value, + AgendaEntry.minTailDays, + AgendaEntry.maxTailDays, + 'agendaLayoutDays', + ); + } + _agendaLayoutDaysChosen = true; + agendaLayoutDays.value = value; + _notify(); + return _writePreference( + 'agendaLayoutDays', + value.toString(), + '保存 DDL 排版宽度', + ); + } + + 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 { @@ -380,6 +474,9 @@ class AppSettingsController extends ChangeNotifier { _systemPaletteSource.dispose(); agendaViewMode.dispose(); timetableHeightMode.dispose(); + deadlineCutoff.dispose(); + agendaLayoutDays.dispose(); + agendaEventInfoBar.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..f1b0b28 --- /dev/null +++ b/lib/app/ui/agenda_layout_days_settings.dart @@ -0,0 +1,142 @@ +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'; + +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 < AgendaEntry.minTailDays || + value > AgendaEntry.maxTailDays) { + 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: AgendaEntry.minTailDays.toDouble(), + max: AgendaEntry.maxTailDays.toDouble(), + divisions: AgendaEntry.maxTailDays - AgendaEntry.minTailDays, + 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/deadline_cutoff_settings.dart b/lib/app/ui/deadline_cutoff_settings.dart new file mode 100644 index 0000000..2f1a02e --- /dev/null +++ b/lib/app/ui/deadline_cutoff_settings.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; + +import '../../agenda/deadline_cutoff.dart'; +import '../../l10n/l10n.dart'; +import '../../ui/app_page.dart'; +import '../../ui/app_pickers.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 window = settings.deadlineCutoff.value; + final minute = start ? window.startMinute : window.endMinute; + final selected = await showAppTimePicker( + context: context, + initialTime: TimeOfDay(hour: minute ~/ 60, minute: minute % 60), + helpText: start + ? context.l10n.deadlineCutoffStart + : context.l10n.deadlineCutoffEnd, + ); + 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; + await settings.setDeadlineCutoff( + start + ? latest.copyWith(startMinute: value) + : latest.copyWith(endMinute: value), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return AppRoutePage( + title: l10n.settingsDeadlineCutoff, + child: ValueListenableBuilder( + valueListenable: settings.deadlineCutoff, + builder: (context, window, _) { + Widget timeTile({required bool start}) => ListTile( + key: ValueKey( + start ? 'deadline-cutoff-start' : 'deadline-cutoff-end', + ), + leading: const Icon(Icons.schedule_rounded), + title: Text( + start ? l10n.deadlineCutoffStart : l10n.deadlineCutoffEnd, + ), + 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()], + ), + ), + 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, + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/app/ui/settings_screen.dart b/lib/app/ui/settings_screen.dart index 34a10a5..a10eddd 100644 --- a/lib/app/ui/settings_screen.dart +++ b/lib/app/ui/settings_screen.dart @@ -19,7 +19,9 @@ import '../../ui/selection_controls.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'; class SettingsScreen extends StatelessWidget { @@ -65,6 +67,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([ @@ -189,6 +202,20 @@ 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), @@ -240,6 +267,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 793c463..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", @@ -658,7 +667,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": { @@ -1747,6 +1757,68 @@ "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": "Show DDLs in this window as due at its start. Actual deadlines stay unchanged.", + "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 at {time} each 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" + } + } + }, + "settingsAgendaLayoutDays": "DDL layout 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 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 c830aff..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: @@ -1634,9 +1658,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: @@ -4326,6 +4356,132 @@ 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: + /// **'Show DDLs in this window as due at its start. Actual deadlines stay unchanged.'** + 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 at {time} each 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 @settingsAgendaLayoutDays. + /// + /// In en, this message translates to: + /// **'DDL layout width'** + String get settingsAgendaLayoutDays; + + /// No description provided for @agendaLayoutDaysDescription. + /// + /// In en, this message translates to: + /// **'Default layout width. Individual DDL tail lengths take priority.'** + 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 @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 details on the left when on, or inside event and exam boxes when off.'** + 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 c386432..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'; @@ -930,7 +952,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) { @@ -2507,6 +2532,84 @@ 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 => + 'Show DDLs in this window as due at its start. Actual deadlines stay unchanged.'; + + @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 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.'; + + @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 get settingsAgendaLayoutDays => 'DDL layout width'; + + @override + String get agendaLayoutDaysDescription => + 'Default layout width. Individual DDL tail lengths take priority.'; + + @override + String get agendaLayoutDaysUnit => 'days'; + + @override + String get agendaLayoutDaysRange => 'Enter a whole number from 1 to 30'; + + @override + String get settingsAgendaEventInfoBar => 'Event information bar'; + + @override + 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) { return '$channel $version'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 27b3404..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 => '小组件使用系统调色板'; @@ -880,7 +895,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) { @@ -2393,6 +2411,81 @@ 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 get settingsAgendaLayoutDays => 'DDL 排版宽度'; + + @override + String get agendaLayoutDaysDescription => '默认排版宽度;单个 DDL 的拖尾长度优先。'; + + @override + String get agendaLayoutDaysUnit => '天'; + + @override + String get agendaLayoutDaysRange => '请输入 1–30 的整数'; + + @override + String get settingsAgendaEventInfoBar => '日程与考试信息栏'; + + @override + String get settingsAgendaEventInfoBarDescription => '开启显示左侧信息栏,关闭使用方框样式。'; + @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 e0c04a7..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": "导入调色板", @@ -260,7 +264,8 @@ "agendaColorPresets": "快捷颜色", "agendaColorCustomSection": "自定义颜色", "agendaTailDays": "拖尾长度(天)", - "agendaTailDaysHint": "1–30 天,默认 4 天", + "agendaTailDaysHint": "1–30 天,单条设置优先", + "agendaTailDaysDefault": "跟随全局设置", "agendaTailDaysValue": "{days} 天", "agendaEndAfterStart": "结束时间需要晚于开始时间", "agendaReminderOverdue": "{time} 已逾期", @@ -708,5 +713,26 @@ "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}", + "settingsAgendaLayoutDays": "DDL 排版宽度", + "agendaLayoutDaysDescription": "默认排版宽度;单个 DDL 的拖尾长度优先。", + "agendaLayoutDaysUnit": "天", + "agendaLayoutDaysRange": "请输入 1–30 的整数", + "settingsAgendaEventInfoBar": "日程与考试信息栏", + "settingsAgendaEventInfoBarDescription": "开启显示左侧信息栏,关闭使用方框样式。", "releaseVersion": "{channel} {version}" } diff --git a/lib/l10n/formats.dart b/lib/l10n/formats.dart index e402133..a839371 100644 --- a/lib/l10n/formats.dart +++ b/lib/l10n/formats.dart @@ -175,14 +175,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) @@ -277,7 +279,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/lib/ui/app_pickers.dart b/lib/ui/app_pickers.dart index cb3444f..82a6f60 100644 --- a/lib/ui/app_pickers.dart +++ b/lib/ui/app_pickers.dart @@ -20,9 +20,11 @@ Future showAppDatePicker({ Future showAppTimePicker({ required BuildContext context, required TimeOfDay initialTime, + String? helpText, }) => showTimePicker( context: context, initialTime: initialTime, + helpText: helpText, // The app's dialog width fits the portrait dial even in a short window. orientation: Orientation.portrait, builder: _pickerDialogBuilder, diff --git a/test/agenda_adjusted_timeline_test.dart b/test/agenda_adjusted_timeline_test.dart new file mode 100644 index 0000000..2edcbe5 --- /dev/null +++ b/test/agenda_adjusted_timeline_test.dart @@ -0,0 +1,411 @@ +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.rawRgba); + image.dispose(); + return data!; + }))!; + +void main() { + 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(); + 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 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, + ) 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 { + 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 * 13.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 * 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()); + }); + + 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]) { + // 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()); + }); + } + + 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/agenda_detail_test.dart b/test/agenda_detail_test.dart index 6dff813..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'; @@ -21,21 +22,23 @@ 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( - id: 'assignment', - title: detailed ? '第三章作业:线性表与链表的实现、测试和复杂度分析' : '1', - kind: AgendaKind.deadline, - startsAt: DateTime.utc(2020, 10, 27, 1, 11), - allDay: allDay, - course: detailed ? '2026–2027 秋季学期 · 数据结构中文班' : '', - source: detailed ? DeadlineSource.lexue : null, - url: detailed ? url : null, - location: detailed ? '文萃楼F704,计算机实验室' : '', - notes: detailed - ? List.filled(20, '提交源码和实验报告,说明边界条件与测试结果。').join('\n') - : '', - ); + AgendaEntry deadline({ + bool detailed = false, + bool allDay = false, + int? tailDays, + }) => 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, @@ -220,85 +223,243 @@ void main() { expect(find.byType(SheetLayout), findsOneWidget); }); - testWidgets('Edit opens the existing editor and saves the same entry', ( + 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( + '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 { - 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); - 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); await tester.tap(find.byKey(const ValueKey('agenda-more-options'))); await tester.pumpAndSettle(); - expect(find.text('备注(选填)'), findsOneWidget); - 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), + 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(); - 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); - 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(); + 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, selectedDays.round()); - 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(tester.widget(tailDays).value, selectedDays); + 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); + }); + + testWidgets('Editing an adjusted coloured DDL saves the real timestamp', ( + tester, + ) async { + final real = DateTime.utc(2030, 11, 11, 7); + final entry = AgendaEntry( + id: 'adjusted-colour', + title: '跨日作业', + kind: AgendaKind.deadline, + startsAt: real, + tailDays: 14, + color: AgendaColor.blue, + done: true, + ); + final controller = await open(tester, entry); + await controller.settings.setDeadlineCutoff( + const DeadlineCutoffWindow(enabled: true), + ); + await tester.pumpAndSettle(); + expect(find.text('22:00 伪截止', findRichText: true), findsOneWidget); + await tester.tap(find.widgetWithText(TextButton, '编辑')); + await tester.pumpAndSettle(); expect( - tester - .widget(find.byKey(const ValueKey('agenda-color-value'))) - .data, - '珊瑚色', + find.descendant( + of: find.byKey(const ValueKey('agenda-start-date')), + matching: find.text('2030-11-11'), + ), + findsOneWidget, ); + expect( + find.descendant( + of: find.byKey(const ValueKey('agenda-start-time')), + matching: find.text('07:00'), + ), + findsOneWidget, + ); + expect(find.byType(Slider), findsNothing); + await tester.tap(find.widgetWithText(FilledButton, '保存')); + await tester.pumpAndSettle(); + final saved = controller.agenda.entries.single; + expect(saved.startsAt, real); + expect(saved.realDueAt, real); + expect(saved.dueAt, DateTime.utc(2030, 11, 10, 22)); + expect(saved.tailDays, 14); + expect(saved.color, AgendaColor.blue); + expect(saved.done, isTrue); 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..441a373 --- /dev/null +++ b/test/agenda_layout_days_test.dart @@ -0,0 +1,399 @@ +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(), + int? tailDays, +}) => AgendaEntry( + id: id, + title: id, + kind: AgendaKind.deadline, + startsAt: _date, + startOnly: startOnly, + tailDays: tailDays, + 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); + }); + + 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('DDL reservations without overrides follow the global $days days', () { + 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), [null, null, null]); + expect(entries.every((e) => e.startsAt == _date), isTrue); + expect(entries.every((e) => e.toJson()['tailDays'] == null), isTrue); + }); + } + + test('Per-item tails override the global width for every DDL type', () { + const cutoff = DeadlineCutoffWindow( + enabled: true, + startMinute: 10 * 60, + endMinute: 18 * 60, + ); + final entries = [ + _entry('plain', tailDays: 1), + _entry('open', startOnly: true, tailDays: 30), + _entry('adjusted', cutoff: cutoff, tailDays: 14), + ]; + for (final days in [4, 8]) { + 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, + ); + } + } + }); + + 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 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, + ), + ); + 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 * (entry.effectiveLayoutDays(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 * (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_start_only_test.dart b/test/agenda_start_only_test.dart index 396805c..f77da54 100644 --- a/test/agenda_start_only_test.dart +++ b/test/agenda_start_only_test.dart @@ -73,16 +73,16 @@ void main() { test('Only a deadline can be open-ended', () { expect( - () => AgendaEntry( - id: 'exam', - title: '期中考试', - kind: AgendaKind.exam, - startsAt: _opens, - endsAt: _opens.add(const Duration(hours: 2)), - startOnly: true, - ), - throwsA(isA()), - ); + () => AgendaEntry( + id: 'exam', + title: '期中考试', + kind: AgendaKind.exam, + startsAt: _opens, + endsAt: _opens.add(const Duration(hours: 2)), + startOnly: true, + ), + throwsA(isA()), + ); }); }); @@ -94,7 +94,10 @@ void main() { _openEnded(startsAt: DateTime.utc(2020, 5, 1, 8)).overdue(now), isFalse, ); - expect(_due(startsAt: DateTime.utc(2020, 5, 1, 8)).overdue(now), isTrue); + expect( + _due(startsAt: DateTime.utc(2020, 5, 1, 8)).overdue(now), + isTrue, + ); }); test('An open-ended deadline stays unfinished until it is ticked off', () { @@ -106,73 +109,32 @@ void main() { }); group('layout', () { - test('The rightward tail yields to the next card in its lane', () { - final open = _openEnded(tailDays: 6); - final next = AgendaEntry( - id: 'next', - title: '日程', - kind: AgendaKind.event, - startsAt: _opens.add(const Duration(hours: 12)), - endsAt: _opens.add(const Duration(hours: 14)), - ); - final layout = AgendaTimelineLayout( - [open, next], + test('The reservation grows right from the opening time', () { + final placement = layoutAgendaTimeline( + [_openEnded(tailDays: 6)], minimumEventSpan: const Duration(hours: 1), - deadlineCardSpan: const Duration(hours: 6), - ); - for (final zoom in [1.0, 4.0, 24.0]) { - final placements = layout.atZoom(zoom); - expect(placements.map((placement) => placement.lane), [0, 0]); - expect(placements.first.paintEnd, next.startsAt); - expect( - placements.first.layoutEnd, - _opens.add(Duration(minutes: (360 / zoom).round())), - ); - } - }); + layoutDays: 6, + ).single; - test( - 'The card grows right from the opening time, followed by its tail', - () { - final placement = AgendaTimelineLayout( - [_openEnded(tailDays: 6)], - minimumEventSpan: const Duration(hours: 1), - deadlineCardSpan: const Duration(hours: 6), - ).atZoom(1).single; - - expect(placement.start, _opens); - expect(placement.end, _opens.add(const Duration(hours: 6))); - expect(placement.layoutEnd, _opens.add(const Duration(hours: 6))); - expect(placement.paintStart, _opens); - expect( - placement.paintEnd, - _opens.add(const Duration(days: 5, hours: 18)), - ); - }, - ); + expect(placement.start, _opens); + expect(placement.end, _opens.add(const Duration(days: 6))); + expect(placement.layoutEnd, _opens.add(const Duration(days: 6))); + }); - test('A deadline with a due date keeps its tail on the left', () { - final placement = AgendaTimelineLayout( + test('A deadline with a due date still reserves its tail on the left', () { + final placement = layoutAgendaTimeline( [_due(tailDays: 6)], minimumEventSpan: const Duration(hours: 1), - deadlineCardSpan: const Duration(hours: 6), - ).atZoom(1).single; + layoutDays: 6, + ).single; - expect(placement.start, _opens.subtract(const Duration(hours: 6))); - expect( - placement.paintStart, - _opens.subtract(const Duration(days: 5, hours: 18)), - ); - expect(placement.paintEnd, _opens); + expect(placement.start, _opens.subtract(const Duration(days: 6))); expect(placement.end, _opens); }); }); group('labels', () { - final localized = [ - AppLocalizationsZh(), - AppLocalizationsEn(), - ]; + final localized = [AppLocalizationsZh(), AppLocalizationsEn()]; test('An open-ended deadline is never presented as due', () { for (final l10n in localized) { @@ -209,7 +171,7 @@ void main() { entry: entry, now: _date, barWidth: 240, - cardWidth: 144, + deadlineWidth: 240, onTap: () {}, ), ), @@ -224,18 +186,10 @@ void main() { find.byKey(ValueKey(('agenda-timeline-bar', entry.id))); LinearGradient fill(WidgetTester tester, AgendaEntry entry) { - final tail = tester.widget( - find.descendant( - of: bar(entry), - matching: find.byWidgetPredicate( - (widget) => - widget is DecoratedBox && - widget.decoration is BoxDecoration && - (widget.decoration as BoxDecoration).gradient != null, - ), - ), + final ink = tester.widget( + find.descendant(of: bar(entry), matching: find.byType(Ink)), ); - return (tail.decoration as BoxDecoration).gradient! as LinearGradient; + return (ink.decoration! as BoxDecoration).gradient! as LinearGradient; } Border outline(WidgetTester tester, AgendaEntry entry) { @@ -275,13 +229,16 @@ void main() { final gradient = fill(tester, open); expect(gradient.colors.first.a, 1); + expect(gradient.colors[1].a, 1); expect(gradient.colors.last.a, 0); + expect(gradient.stops, [0.0, 0.82, 1.0]); final due = _due(); await showCard(tester, due); final standard = fill(tester, due); expect(standard.colors.first.a, 0); expect(standard.colors.last.a, 1); + expect(standard.stops, [0.0, 0.18, 1.0]); }); testWidgets('Text hugs the line on the left', (tester) async { @@ -304,20 +261,22 @@ 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), home: Scaffold( body: AgendaTimeline( - controller: app.agenda, + controller: app, entries: entries, now: _date.add(const Duration(hours: 12)), ), @@ -327,15 +286,16 @@ void main() { await tester.pumpAndSettle(); } - Rect bar(WidgetTester tester, AgendaEntry entry) => - tester.getRect(find.byKey(ValueKey(('agenda-timeline-bar', entry.id)))); + Rect bar(WidgetTester tester, AgendaEntry entry) => tester.getRect( + find.byKey(ValueKey(('agenda-timeline-bar', entry.id))), + ); testWidgets('The card keeps the deadline width and grows rightwards', ( tester, ) 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); @@ -351,19 +311,20 @@ 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); await tester.pumpAndSettle(); final after = bar(tester, open); - // The tail keeps its calendar span as the time axis expands. + // The painted width is set by the minimum zoom, so the card keeps its + // screen width while the lane reservation behind it stretches. expect( find.byKey(ValueKey(('agenda-timeline-bar', open.id))), findsOneWidget, ); - expect(after.width, closeTo(before.width * 24, .001)); + expect(after.width, closeTo(before.width, .001)); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox()); }); diff --git a/test/agenda_swipe_test.dart b/test/agenda_swipe_test.dart index 8835940..580ad79 100644 --- a/test/agenda_swipe_test.dart +++ b/test/agenda_swipe_test.dart @@ -379,22 +379,6 @@ 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); - }); - testWidgets('Swiping away deletion feedback keeps the deadline deleted', ( tester, ) async { diff --git a/test/agenda_timeline_colors_test.dart b/test/agenda_timeline_colors_test.dart index 4a46ae2..388f303 100644 --- a/test/agenda_timeline_colors_test.dart +++ b/test/agenda_timeline_colors_test.dart @@ -80,6 +80,31 @@ void main() { } }); + testWidgets('Custom translucent colors keep multiplicative completion alpha in $brightness', ( + tester, + ) async { + late BuildContext context; + await tester.pumpWidget(MaterialApp( + theme: bitreeTheme(brightness), + home: Builder(builder: (value) { + context = value; + return const SizedBox(); + }), + )); + final entry = AgendaEntry( + id: 'translucent', title: '半透明', kind: AgendaKind.deadline, + startsAt: start, color: const AgendaColor(0x803366CC), + ); + final pending = agendaTimelineColors(context, entry, now); + final completed = agendaTimelineColors( + context, entry.copyWith(done: true), now, + ); + expect(pending.accent.a, closeTo(128 / 255, .001)); + expect(completed.accent.a, closeTo(pending.accent.a * .5, .001)); + expect(completed.fill.a, closeTo(pending.fill.a * .5, .001)); + expect(completed.text.a, closeTo(pending.text.a * .5, .001)); + }); + testWidgets('Overdue DDLs use error semantic colors in $brightness', ( tester, ) async { @@ -115,5 +140,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_entry_test.dart b/test/agenda_timeline_entry_test.dart index 6acc33c..a935999 100644 --- a/test/agenda_timeline_entry_test.dart +++ b/test/agenda_timeline_entry_test.dart @@ -1,4 +1,4 @@ -import 'package:bitree/agenda/deadline_source.dart'; +import 'package:bitree/agenda/deadline_source.dart'; import 'package:bitree/agenda/model.dart'; import 'package:bitree/agenda/ui/timeline_entry.dart'; import 'package:bitree/ui/theme.dart'; @@ -38,7 +38,7 @@ void main() { entry: value, now: now, barWidth: 180, - cardWidth: 144, + deadlineWidth: 180, onTap: () {}, ), ), @@ -60,22 +60,8 @@ void main() { return ink.decoration! as BoxDecoration; } - List tailFill() { - final tail = tester.widget( - find.descendant( - of: bar, - matching: find.byWidgetPredicate( - (widget) => - widget is DecoratedBox && - widget.decoration is BoxDecoration && - (widget.decoration as BoxDecoration).gradient != null, - ), - ), - ); - return ((tail.decoration as BoxDecoration).gradient! - as LinearGradient) - .colors; - } + List fill() => + (fillDecoration().gradient! as LinearGradient).colors; Border border() { final foreground = find.descendant( @@ -116,7 +102,7 @@ void main() { } await show(entry, due.subtract(const Duration(minutes: 1))); - final originalFill = fillDecoration().color!; + final originalFill = fill(); final originalText = titleColor(); final originalCaption = captionColor('13:00 截止'); final originalBorder = border().right; @@ -130,9 +116,14 @@ void main() { await show(entry, due.add(const Duration(minutes: 1))); final warningBackground = theme.colorScheme.errorContainer; final warningForeground = theme.colorScheme.onErrorContainer; - expect(fillDecoration().color, warningBackground); - expect(tailFill().first.a, 0); - expect(tailFill().last, warningBackground); + expect(fillDecoration().color, isNull); + expect((fillDecoration().gradient! as LinearGradient).stops, const [ + 0, + 0.18, + 1, + ]); + expect(fill().first.a, 0); + expect(fill().last, warningBackground); expect(titleColor(), warningForeground); expect( border().right, @@ -160,7 +151,7 @@ void main() { entry.copyWith(done: true), due.add(const Duration(minutes: 1)), ); - expect(fillDecoration().color, originalFill.withValues(alpha: 0.5)); + expect(fill().last, originalFill.last.withValues(alpha: 0.5)); expect(titleColor(), originalText.withValues(alpha: 0.5)); expect( captionColor('13:00 截止'), diff --git a/test/agenda_timeline_projection_test.dart b/test/agenda_timeline_projection_test.dart new file mode 100644 index 0000000..bc73b35 --- /dev/null +++ b/test/agenda_timeline_projection_test.dart @@ -0,0 +1,139 @@ +import 'package:bitree/agenda/model.dart'; +import 'package:bitree/agenda/timeline_layout.dart'; +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, + ); + + 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, + startsAt: day.add(const Duration(hours: 14)), + deadlineCutoffWindow: window, + ); + final event = AgendaEntry( + id: 'event', + title: '延伸段内日程', + kind: AgendaKind.event, + startsAt: day.add(const Duration(hours: 11)), + endsAt: day.add(const Duration(hours: 12)), + ); + final layout = AgendaTimelineLayout( + [adjusted, event], + minimumEventSpan: const Duration(minutes: 1), + deadlineCardSpan: const Duration(hours: 90), + ); + for (final zoom in [1.0, 4.0, 24.0]) { + final projected = layout.atZoom(zoom); + expect(projected.map((p) => p.lane).toSet(), hasLength(2)); + final ddl = projected.singleWhere((p) => p.entry.id == adjusted.id); + expect(ddl.paintEnd, adjusted.realDueAt); + expect(ddl.paintStart.isBefore(adjusted.dueAt), isTrue); + } + }); +} diff --git a/test/agenda_timeline_test.dart b/test/agenda_timeline_test.dart index 19df899..28a0135 100644 --- a/test/agenda_timeline_test.dart +++ b/test/agenda_timeline_test.dart @@ -1,8 +1,7 @@ -import 'dart:math' as math; - 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/l10n/app_localizations_zh.dart'; import 'package:bitree/l10n/formats.dart'; @@ -33,6 +32,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( @@ -43,11 +52,21 @@ List _stackedDeadlines() => [ ), ]; +ScrollPosition _lanePosition(WidgetTester tester) => tester + .state( + find.descendant( + of: find.byKey(const ValueKey('agenda-timeline-lanes')), + matching: find.byType(Scrollable), + ), + ) + .position; + Future _show( WidgetTester tester, List entries, { double scale = 1, Brightness brightness = Brightness.light, + int layoutDays = 4, ValueNotifier? visible, }) async { tester.view.physicalSize = const Size(390, 844); @@ -56,6 +75,7 @@ Future _show( addTearDown(tester.view.resetDevicePixelRatio); final app = TestAppController(entries: entries); await app.initialize(); + await app.settings.setAgendaLayoutDays(layoutDays); final visibility = visible ?? ValueNotifier(true); if (visible == null) addTearDown(visibility.dispose); await tester.pumpWidget( @@ -70,7 +90,7 @@ Future _show( body: ValueListenableBuilder( valueListenable: visibility, builder: (context, isVisible, _) => AgendaTimeline( - controller: app.agenda, + controller: app, entries: entries, now: _date.add(const Duration(hours: 12)), visible: isVisible, @@ -83,15 +103,6 @@ Future _show( return app; } -ScrollPosition _lanePosition(WidgetTester tester) => tester - .state( - find.descendant( - of: find.byKey(const ValueKey('agenda-timeline-lanes')), - matching: find.byType(Scrollable), - ), - ) - .position; - Future _wheel( WidgetTester tester, Offset delta, { @@ -162,7 +173,7 @@ void main() { closeTo(heldTop, .01), ); } - expect(position.maxScrollExtent, greaterThan(maximum)); + expect(position.maxScrollExtent, closeTo(maximum, .01)); await gesture.up(); released = true; await tester.pumpAndSettle(); @@ -203,7 +214,7 @@ void main() { builder: (context, setState) { refresh = setState; return AgendaTimeline( - controller: app.agenda, + controller: app, entries: entries.toList(), now: _date.add(const Duration(hours: 12)), ); @@ -298,7 +309,7 @@ void main() { ), home: Scaffold( body: AgendaTimeline( - controller: app.agenda, + controller: app, entries: entries, now: now, ), @@ -327,9 +338,9 @@ void main() { ); expect( tester - .getSize(find.byKey(ValueKey(('agenda-timeline-card', entry.id)))) + .getSize(_paintedBar(entry)) .width, - closeTo(144 * scale, .001), + closeTo(180, .001), ); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox()); @@ -361,178 +372,13 @@ void main() { ); expect( tester.getRect(_paintedBar(tracked)).width, - closeTo(180 * zoom, .001), + closeTo(180, .001), ); } expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox()); }); - testWidgets('Long tails do not enlarge the cached card layer', ( - tester, - ) async { - final ddl = AgendaEntry( - id: 'long-render-layer', - title: '长期作业', - kind: AgendaKind.deadline, - startsAt: _date.add(const Duration(hours: 13)), - tailDays: 30, - ); - await _show(tester, [ddl]); - tester.widget(find.byType(Slider)).onChanged!(24); - await tester.pumpAndSettle(); - final card = tester.getRect(_bar(ddl)); - final layer = tester.getRect( - find.descendant( - of: find.byKey(ValueKey(ddl.id)), - matching: find.byType(RepaintBoundary), - ), - ); - final tail = tester.getRect(_paintedBar(ddl)); - expect(layer, card); - expect(tail.width, closeTo(48 * 29.75 * 24, .001)); - expect(card.right, closeTo(243, .001)); - await tester.tapAt(card.center); - await tester.pumpAndSettle(); - expect(find.byType(BottomSheet), findsOneWidget); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox()); - }); - - test('Minimum display spans separate short events without changing their real times', () { - final first = _event('first', 11); - final second = _event('second', 14, exam: true); - final entries = [first, second]; - final layout = AgendaTimelineLayout( - entries, - minimumEventSpan: const Duration(hours: 48), - deadlineCardSpan: const Duration(days: 2), - ); - final zoomedOut = layout.atZoom(1); - expect(zoomedOut.map((placement) => placement.lane), [0, 1]); - expect(zoomedOut.first.start, first.startsAt); - expect(zoomedOut.first.end, first.endsAt); - expect( - zoomedOut.first.layoutEnd, - first.startsAt.add(const Duration(hours: 48)), - ); - final zoomedIn = layout.atZoom(24); - expect(zoomedIn.map((placement) => placement.lane), [0, 1]); - expect(zoomedIn.map((placement) => placement.end), [ - first.endsAt, - second.endsAt, - ]); - }); - - test('Long events and DDL endpoints retain their real times', () { - final long = AgendaEntry( - id: 'long', - title: '多日日程', - kind: AgendaKind.event, - startsAt: _date, - endsAt: _date.add(const Duration(days: 5)), - ); - final ddl = AgendaEntry( - id: 'ddl', - title: '作业', - kind: AgendaKind.deadline, - startsAt: _date.add(const Duration(hours: 13)), - ); - final placements = AgendaTimelineLayout( - [long, ddl], - minimumEventSpan: const Duration(days: 2), - deadlineCardSpan: const Duration(days: 2), - ).atZoom(1); - final eventPlacement = placements.singleWhere( - (placement) => placement.entry == long, - ); - final deadlinePlacement = placements.singleWhere( - (placement) => placement.entry == ddl, - ); - expect(eventPlacement.layoutEnd, long.endsAt); - expect( - deadlinePlacement.start, - ddl.dueAt.subtract(const Duration(days: 2)), - ); - expect( - deadlinePlacement.paintStart, - ddl.dueAt.subtract(const Duration(days: 3, hours: 18)), - ); - expect(deadlinePlacement.layoutEnd, ddl.dueAt); - }); - - for (final days in [1, 7, 14]) { - test('A $days-day DDL tail yields to another card in the same lane', () { - final ddl = AgendaEntry( - id: 'custom', - title: '作业', - kind: AgendaKind.deadline, - startsAt: _date, - allDay: true, - tailDays: days, - ); - final overlapping = AgendaEntry( - id: 'overlap', - title: '日程', - kind: AgendaKind.event, - startsAt: ddl.dueAt.subtract(const Duration(hours: 12)), - endsAt: ddl.dueAt.subtract(const Duration(hours: 10)), - ); - final layout = AgendaTimelineLayout( - [ddl, overlapping], - minimumEventSpan: const Duration(minutes: 1), - deadlineCardSpan: const Duration(hours: 6), - ); - for (final zoom in [1.0, 4.0, 24.0]) { - final placements = layout.atZoom(zoom); - final reservation = placements.singleWhere((p) => p.entry == ddl); - expect( - reservation.start, - ddl.dueAt.subtract(Duration(minutes: (360 / zoom).round())), - ); - expect(reservation.paintStart, overlapping.endsAt); - expect(reservation.end, ddl.dueAt); - expect(placements.map((p) => p.lane).toSet(), hasLength(1)); - } - }); - - for (final startOnly in [false, true]) { - testWidgets( - 'A $days-day DDL tail scales with calendar time on zoom, startOnly=$startOnly', - (tester) async { - final ddl = AgendaEntry( - id: 'custom', - title: '作业', - kind: AgendaKind.deadline, - startsAt: _date.add(const Duration(hours: 13)), - tailDays: days, - startOnly: startOnly, - ); - await _show(tester, [ddl]); - final before = tester.getRect(_paintedBar(ddl)); - expect(before.width, closeTo(math.max(144, 48 * (days - .25)), .001)); - for (final zoom in [2.0, 8.0, 24.0, 4.0, 1.0]) { - tester.widget(find.byType(Slider)).onChanged!(zoom); - await tester.pumpAndSettle(); - final after = tester.getRect(_paintedBar(ddl)); - expect( - after.width, - closeTo(math.max(144, 48 * (days - .25) * zoom), .001), - ); - expect( - startOnly ? after.left : after.right, - closeTo(195 + 2 * zoom, .001), - ); - expect(after.top, closeTo(before.top, .001)); - expect(tester.getRect(_bar(ddl)).width, closeTo(144, .001)); - } - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox()); - }, - ); - } - } - testWidgets('Zoom keeps mixed entries in their original lanes', ( tester, ) async { @@ -586,7 +432,7 @@ void main() { id: 'pinch-second', title: '第二份作业', kind: AgendaKind.deadline, - startsAt: _date.add(const Duration(hours: 24)), + startsAt: _date.add(const Duration(hours: 12, minutes: 30)), ); await _show(tester, [first, second]); final originalTop = tester.getRect(_paintedBar(second)).top; @@ -668,318 +514,69 @@ void main() { await tester.pumpWidget(const SizedBox()); }); - testWidgets('Long DDL tails stay visible when the deadline is off screen', ( - tester, - ) async { - final ddl = AgendaEntry( - id: 'future', - title: '下周作业', - kind: AgendaKind.deadline, - startsAt: _date.add(const Duration(days: 7)), - tailDays: 14, - ); - await _show(tester, [ddl]); - final viewport = tester.getRect(find.byType(AgendaTimeline)); - final tail = tester.getRect(_paintedBar(ddl)); - expect(tail.right, greaterThan(viewport.right)); - expect(tail.left, lessThan(viewport.right)); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox()); - }); - - for (final (brightness, scale) in [ - (Brightness.light, 1.0), - (Brightness.dark, 1.6), - ]) { + for (final cancel in [false, true]) { testWidgets( - 'Short cards keep a fixed 2px minimum and stay tappable in $brightness, $scale', + 'Repeated top-edge pulls rebound after ${cancel ? 'cancellation' : 'release'}', (tester) async { - final first = _event('first', 11); - final second = _event('second', 14, exam: true); - final ddl = AgendaEntry( - id: 'ddl', - title: '第五章作业', - kind: AgendaKind.deadline, - startsAt: _date.add(const Duration(hours: 13)), - ); - final entries = [first, second, ddl]; - final app = await _show( - tester, - entries, - scale: scale, - brightness: brightness, - ); - final viewport = tester.getRect(find.byType(AgendaTimeline)); - void checkCards(double eventWidth, {double zoom = 1}) { - 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(math.max(180 * zoom, 144 * scale), .001), - ); - for (var index = 0; index < rectangles.length; index++) { - expect( - rectangles[index].height, - closeTo(tester.getRect(_bar(entries[index])).height, .001), - ); - for (var other = index + 1; other < rectangles.length; other++) { - expect(rectangles[index].overlaps(rectangles[other]), isFalse); - } + await _show(tester, []); + final position = _lanePosition(tester); + for (var attempt = 0; attempt < 3; attempt++) { + final gesture = await tester.startGesture(const Offset(200, 180)); + await gesture.moveBy(const Offset(0, 30)); + await tester.pump(const Duration(milliseconds: 40)); + await gesture.moveBy(const Offset(0, 80)); + await tester.pump(const Duration(milliseconds: 40)); + expect(position.pixels, lessThan(0)); + if (cancel) { + await gesture.cancel(); + } else { + await gesture.up(); } - expect( - tester.getSemantics(_bar(first)).label, - contains('11:00–11:05'), - ); + await tester.pumpAndSettle(); + expect(position.pixels, closeTo(0, .01)); expect(tester.takeException(), isNull); } - - checkCards(2); - 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, zoom: 24); - final short = tester.getRect(_bar(first)); - await tester.tapAt(Offset(short.right - 12, short.center.dy)); - await tester.pumpAndSettle(); - expect( - find.descendant( - of: find.byType(BottomSheet), - matching: find.text(first.title), - ), - findsWidgets, - ); - expect(app.agenda.entries, orderedEquals(entries)); - expect(first.endsAt, _date.add(const Duration(hours: 11, minutes: 5))); - await tester.tap(find.byTooltip('关闭')); - await tester.pumpAndSettle(); - expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox()); }, ); } testWidgets( - 'A minimum-width card stays visible after its real end leaves the viewport', + 'Pulling past the bottom rebounds without growing the blank range', (tester) async { - final beforeViewport = _date - .subtract(const Duration(days: 4)) - .add(const Duration(hours: 10)); - final entry = AgendaEntry( - id: 'edge', - title: '边缘短日程', - kind: AgendaKind.event, - startsAt: beforeViewport, - endsAt: beforeViewport.add(const Duration(minutes: 5)), - ); - await _show(tester, [entry]); - 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)); - final card = tester.getRect(_bar(entry)); - expect(card.left, 0); - await tester.tapAt(card.center); + final entries = [ + for (var index = 0; index < 20; index++) + AgendaEntry( + id: 'bottom-$index', + title: '重叠日程 $index', + kind: AgendaKind.event, + startsAt: _date.add(const Duration(hours: 11)), + endsAt: _date.add(const Duration(hours: 14)), + ), + ]; + await _show(tester, entries); + final position = _lanePosition(tester); + final maximum = position.maxScrollExtent; + position.jumpTo(maximum); await tester.pumpAndSettle(); - expect(find.byType(BottomSheet), findsOneWidget); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox()); - }, - ); - - testWidgets('Off-screen lanes do not add vertical blank pages', ( - tester, - ) async { - final current = _event('current', 11); - final farDay = _date.add(const Duration(days: 30)); - final offScreen = [ - for (var index = 0; index < 20; index++) - AgendaEntry( - id: 'off-screen-$index', - title: '远处日程 $index', - kind: AgendaKind.event, - startsAt: farDay.add(Duration(hours: 10, minutes: index)), - endsAt: farDay.add(const Duration(hours: 14)), - ), - ]; - await _show(tester, [current, ...offScreen]); - - final scrollable = tester.state( - find.descendant( - of: find.byKey(const ValueKey('agenda-timeline-lanes')), - matching: find.byType(Scrollable), - ), - ); - expect(scrollable.position.maxScrollExtent, 0); - expect(_paintedBar(current), findsOneWidget); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox()); - }); - - testWidgets('Overlapping entries remain reachable below the viewport', ( - tester, - ) async { - final entries = [ - for (var index = 0; index < 20; index++) - AgendaEntry( - id: 'overlap-$index', - title: '重叠日程 $index', - kind: AgendaKind.event, - startsAt: _date.add(Duration(hours: 11, minutes: index)), - endsAt: _date.add(const Duration(hours: 14)), - ), - ]; - await _show(tester, entries); - - final scrollable = tester.state( - find.descendant( - of: find.byKey(const ValueKey('agenda-timeline-lanes')), - matching: find.byType(Scrollable), - ), - ); - expect(scrollable.position.maxScrollExtent, greaterThan(0)); - scrollable.position.jumpTo(scrollable.position.maxScrollExtent); - await tester.pumpAndSettle(); - expect(_paintedBar(entries.last), findsOneWidget); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox()); - }); - - testWidgets( - 'Dense-to-sparse panning holds the vertical position until the user scrolls up', - (tester) async { - final entries = [ - for (var index = 0; index < 20; index++) - AgendaEntry( - id: 'dense-$index', - title: '密集日程 $index', - kind: AgendaKind.event, - startsAt: _date.add(Duration(hours: 11, minutes: index)), - endsAt: _date.add(const Duration(hours: 14)), - ), - AgendaEntry( - id: 'sparse', - title: '后续日程', - kind: AgendaKind.event, - startsAt: _date.add(const Duration(days: 12, hours: 11)), - endsAt: _date.add(const Duration(days: 12, hours: 14)), - ), - ]; - await _show(tester, entries); - final position = _lanePosition(tester); - final denseMaximum = position.maxScrollExtent; - expect(denseMaximum, greaterThan(200)); - position.jumpTo(denseMaximum * .75); - await tester.pumpAndSettle(); - final before = position.pixels; - - await _wheel(tester, const Offset(48 * 12, 0)); - expect(_paintedBar(entries.last), findsNothing); - expect(position.pixels, closeTo(before, .01)); - expect(position.maxScrollExtent, closeTo(before, .01)); - - await _wheel(tester, const Offset(0, -80), vertical: true); - final tightened = position.pixels; - expect(tightened, closeTo(before - 80, .01)); - expect(position.maxScrollExtent, closeTo(tightened, .01)); - await _wheel(tester, const Offset(0, 250), vertical: true); - expect(position.pixels, closeTo(tightened, .01)); - - await _wheel(tester, const Offset(0, -2000), vertical: true); - expect(position.pixels, 0); - expect(position.maxScrollExtent, 0); - expect(_paintedBar(entries.last), findsOneWidget); - - await _wheel(tester, const Offset(-48 * 12, 0)); - expect(position.pixels, 0); - expect(position.maxScrollExtent, closeTo(denseMaximum, .01)); - position.jumpTo(position.maxScrollExtent); - await tester.pumpAndSettle(); - final viewport = tester.getRect( - find.byKey(const ValueKey('agenda-timeline-lanes')), - ); - final lastCard = tester.getRect(_paintedBar(entries[19])); - final laneHeight = lastCard.height + 12; - expect( - viewport.bottom - lastCard.bottom, - inInclusiveRange(laneHeight, laneHeight + 24), - ); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox()); - }, - ); - - for (final cancel in [false, true]) { - testWidgets( - 'Repeated top-edge pulls rebound after ${cancel ? 'cancellation' : 'release'}', - (tester) async { - await _show(tester, []); - final position = _lanePosition(tester); - for (var attempt = 0; attempt < 3; attempt++) { - final gesture = await tester.startGesture(const Offset(200, 180)); - await gesture.moveBy(const Offset(0, 30)); - await tester.pump(const Duration(milliseconds: 40)); - await gesture.moveBy(const Offset(0, 80)); - await tester.pump(const Duration(milliseconds: 40)); - expect(position.pixels, lessThan(0)); - if (cancel) { - await gesture.cancel(); - } else { - await gesture.up(); - } - await tester.pumpAndSettle(); - expect(position.pixels, closeTo(0, .01)); - expect(tester.takeException(), isNull); - } - await tester.pumpWidget(const SizedBox()); - }, - ); - } - - testWidgets( - 'Pulling past the bottom rebounds without growing the blank range', - (tester) async { - final entries = [ - for (var index = 0; index < 20; index++) - AgendaEntry( - id: 'bottom-$index', - title: '重叠日程 $index', - kind: AgendaKind.event, - startsAt: _date.add(const Duration(hours: 11)), - endsAt: _date.add(const Duration(hours: 14)), - ), - ]; - await _show(tester, entries); - final position = _lanePosition(tester); - final maximum = position.maxScrollExtent; - position.jumpTo(maximum); - await tester.pumpAndSettle(); - for (final cancel in [false, true]) { - final gesture = await tester.startGesture(const Offset(200, 600)); - await gesture.moveBy(const Offset(0, -30)); - await tester.pump(const Duration(milliseconds: 40)); - await gesture.moveBy(const Offset(0, -80)); - await tester.pump(const Duration(milliseconds: 40)); - expect(position.pixels, greaterThan(maximum)); - expect(position.maxScrollExtent, closeTo(maximum, .01)); - if (cancel) { - await gesture.cancel(); - } else { - await gesture.up(); - } - await tester.pumpAndSettle(); - expect(position.pixels, closeTo(maximum, .01)); - expect(position.maxScrollExtent, closeTo(maximum, .01)); - expect(tester.takeException(), isNull); - } + for (final cancel in [false, true]) { + final gesture = await tester.startGesture(const Offset(200, 600)); + await gesture.moveBy(const Offset(0, -30)); + await tester.pump(const Duration(milliseconds: 40)); + await gesture.moveBy(const Offset(0, -80)); + await tester.pump(const Duration(milliseconds: 40)); + expect(position.pixels, greaterThan(maximum)); + expect(position.maxScrollExtent, closeTo(maximum, .01)); + if (cancel) { + await gesture.cancel(); + } else { + await gesture.up(); + } + await tester.pumpAndSettle(); + expect(position.pixels, closeTo(maximum, .01)); + expect(position.maxScrollExtent, closeTo(maximum, .01)); + expect(tester.takeException(), isNull); + } await tester.pumpWidget(const SizedBox()); }, ); @@ -1033,42 +630,349 @@ void main() { }, ); - testWidgets('DDL cards share lanes without their tails stealing taps', ( - tester, - ) async { - final first = AgendaEntry( - id: 'first-ddl', - title: '第一份作业', + 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]; + final zoomedOut = layoutAgendaTimeline( + entries, + minimumEventSpan: const Duration(hours: 48), + ); + expect(zoomedOut.map((placement) => placement.lane), [0, 1]); + expect( + zoomedOut.first.start, + first.startsAt.subtract(const Duration(days: 4)), + ); + expect(zoomedOut.first.end, first.endsAt); + expect( + zoomedOut.first.layoutEnd, + first.startsAt.add(const Duration(hours: 48)), + ); + final zoomedIn = layoutAgendaTimeline( + entries, + minimumEventSpan: const Duration(hours: 2), + ); + // 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, + ]); + }); + + test('Long events and DDL reservations retain their full intervals', () { + final long = AgendaEntry( + id: 'long', + title: '多日日程', + kind: AgendaKind.event, + startsAt: _date, + endsAt: _date.add(const Duration(days: 5)), + ); + final ddl = AgendaEntry( + id: 'ddl', + title: '作业', kind: AgendaKind.deadline, startsAt: _date.add(const Duration(hours: 13)), ); - final second = AgendaEntry( - id: 'second-ddl', - title: '第二份作业', + final placements = layoutAgendaTimeline([ + long, + ddl, + ], minimumEventSpan: const Duration(days: 2)); + final eventPlacement = placements.singleWhere( + (placement) => placement.entry == long, + ); + 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, + ddl.dueAt.subtract(const Duration(days: 4)), + ); + expect(deadlinePlacement.layoutEnd, ddl.dueAt); + }); + + for (final days in [1, 7, 14]) { + test('DDL lane reservations follow $days global layout days', () { + final ddl = AgendaEntry( + id: 'custom', + title: '作业', + kind: AgendaKind.deadline, + startsAt: _date, + allDay: true, + tailDays: days, + ); + final overlapping = AgendaEntry( + id: 'overlap', + title: '日程', + kind: AgendaKind.event, + startsAt: ddl.dueAt.subtract(Duration(days: days, hours: -1)), + endsAt: ddl.dueAt.subtract(Duration(days: days, hours: -2)), + ); + final placements = layoutAgendaTimeline([ + ddl, + overlapping, + ], 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); + expect(placements.map((p) => p.lane).toSet(), hasLength(2)); + }); + + testWidgets('A $days-day DDL tail keeps its configured width on zoom', ( + tester, + ) async { + final ddl = AgendaEntry( + id: 'custom', + title: '作业', + kind: AgendaKind.deadline, + startsAt: _date.add(const Duration(hours: 13)), + tailDays: days, + ); + 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); + await tester.pumpAndSettle(); + final after = tester.getRect(_paintedBar(ddl)); + expect(after.width, closeTo(before.width, .001)); + expect(after.right, closeTo(243, .001)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + } + + testWidgets('Long DDL tails stay visible when the deadline is off screen', ( + tester, + ) async { + final ddl = AgendaEntry( + id: 'future', + title: '下周作业', kind: AgendaKind.deadline, - startsAt: _date.add(const Duration(days: 3, hours: 13)), + startsAt: _date.add(const Duration(days: 7)), + tailDays: 14, ); - await _show(tester, [first, second]); - final firstCard = tester.getRect(_bar(first)); - final secondCard = tester.getRect(_bar(second)); - expect(firstCard.top, secondCard.top); - expect(firstCard.overlaps(secondCard), isFalse); - final firstTail = tester.getRect(_paintedBar(first)); - final secondTail = tester.getRect(_paintedBar(second)); - expect(secondTail.left, greaterThanOrEqualTo(firstCard.right - .01)); - expect(firstTail.left, lessThan(firstCard.left)); - await tester.tapAt(Offset(firstCard.left - 10, firstCard.center.dy)); - await tester.pumpAndSettle(); - expect(find.byType(BottomSheet), findsNothing); - await tester.tapAt(firstCard.center); + 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)); + expect(tail.left, lessThan(viewport.right)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + for (final (brightness, scale) in [ + (Brightness.light, 1.0), + (Brightness.dark, 1.6), + ]) { + testWidgets( + 'Short events keep fixed information cards and real time spans in $brightness, $scale', + (tester) async { + // 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', + title: '第五章作业', + kind: AgendaKind.deadline, + startsAt: _date.add(const Duration(hours: 13)), + ); + final entries = [first, second, ddl]; + final app = await _show( + tester, + entries, + scale: scale, + brightness: brightness, + ); + void checkCards(double realIntervalWidth) { + final rectangles = entries + .map((entry) => tester.getRect(_paintedBar(entry))) + .toList(); + 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), + ); + for (var other = index + 1; other < rectangles.length; other++) { + 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('13:00–13:05'), + ); + expect(tester.takeException(), isNull); + } + + // 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(); + checkCards(48 * 5 / 60); + final short = tester.getRect(_bar(first)); + await tester.tapAt(Offset(short.right - 12, short.center.dy)); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byType(BottomSheet), + matching: find.text(first.title), + ), + findsWidgets, + ); + expect(app.agenda.entries, orderedEquals(entries)); + expect(first.endsAt, _date.add(const Duration(hours: 13, minutes: 5))); + await tester.tap(find.byTooltip('关闭')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }, + ); + } + + testWidgets( + 'A future event stays tappable while only its information tail is visible', + (tester) async { + final afterViewport = _date.add(const Duration(days: 5, hours: 12)); + final entry = AgendaEntry( + id: 'edge', + title: '边缘短日程', + kind: AgendaKind.event, + 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.width, closeTo(180, .001)); + expect(painted.left, lessThan(viewport.right)); + expect(painted.right, greaterThan(viewport.right)); + final card = tester.getRect(_bar(entry)); + 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); + await tester.pumpWidget(const SizedBox()); + }, + ); + + 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 { + final current = _event('current', 11); + final farDay = _date.add(const Duration(days: 30)); + final offScreen = [ + for (var index = 0; index < 20; index++) + AgendaEntry( + id: 'off-screen-$index', + title: '远处日程 $index', + kind: AgendaKind.event, + startsAt: farDay.add(Duration(hours: 10, minutes: index)), + endsAt: farDay.add(const Duration(hours: 14)), + ), + ]; + await _show(tester, [current, ...offScreen]); + + final scrollable = tester.state( + find.descendant( + of: find.byKey(const ValueKey('agenda-timeline-lanes')), + matching: find.byType(Scrollable), + ), + ); + expect(scrollable.position.maxScrollExtent, greaterThan(0)); + expect(_paintedBar(current), findsOneWidget); + for (final entry in offScreen) { + expect(_paintedBar(entry), findsNothing); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('Overlapping entries remain reachable below the viewport', ( + tester, + ) async { + final entries = [ + for (var index = 0; index < 20; index++) + AgendaEntry( + id: 'overlap-$index', + title: '重叠日程 $index', + kind: AgendaKind.event, + startsAt: _date.add(Duration(hours: 11, minutes: index)), + endsAt: _date.add(const Duration(hours: 14)), + ), + ]; + await _show(tester, entries); + + final scrollable = tester.state( + find.descendant( + of: find.byKey(const ValueKey('agenda-timeline-lanes')), + matching: find.byType(Scrollable), + ), + ); + expect(scrollable.position.maxScrollExtent, greaterThan(0)); + scrollable.position.jumpTo(scrollable.position.maxScrollExtent); await tester.pumpAndSettle(); - expect(find.byType(BottomSheet), findsOneWidget); + expect(_paintedBar(entries.last), findsOneWidget); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox()); }); testWidgets( - 'Diagonal touch panning preserves direction while releasing blank range', + 'Diagonal touch panning keeps the global range and vertical direction', (tester) async { final entries = _stackedDeadlines(); await _show(tester, entries); @@ -1099,8 +1003,11 @@ void main() { timeStamp: Duration(milliseconds: step * 32), ); await tester.pump(const Duration(milliseconds: 32)); - expect(position.maxScrollExtent, lessThanOrEqualTo(maximum + .001)); - expect(position.pixels, greaterThanOrEqualTo(previousOffset - .001)); + expect(position.maxScrollExtent, closeTo(maximum, .001)); + expect( + position.pixels, + greaterThanOrEqualTo(previousOffset - .001), + ); previousOffset = position.pixels; } expect(position.pixels, greaterThan(initialOffset)); @@ -1115,7 +1022,7 @@ void main() { timeStamp: Duration(milliseconds: step * 32), ); await tester.pump(const Duration(milliseconds: 32)); - expect(position.maxScrollExtent, lessThanOrEqualTo(maximum + .001)); + expect(position.maxScrollExtent, closeTo(maximum, .001)); expect(position.pixels, lessThanOrEqualTo(previousOffset + .001)); previousOffset = position.pixels; } @@ -1140,19 +1047,9 @@ void main() { testWidgets('Filtering a deep timeline culls at the newly clamped offset', ( tester, ) async { - final entries = [ - for (var index = 0; index < 24; index++) - AgendaEntry( - id: 'filtered-$index', - title: '重叠作业 $index', - kind: AgendaKind.deadline, - startsAt: _date.add(const Duration(hours: 12)), - term: index == 23 ? 'selected' : 'other', - ), - ]; + final entries = _stackedDeadlines(); final app = await _show(tester, entries); var filtered = entries; - String? selectedTerm; late StateSetter updateEntries; // Retain the same timeline State when the filtered data changes. await tester.pumpWidget( @@ -1163,10 +1060,9 @@ void main() { builder: (_, setState) { updateEntries = setState; return AgendaTimeline( - controller: app.agenda, + controller: app, entries: filtered, now: _date.add(const Duration(hours: 12)), - filter: (kind: null, term: selectedTerm), ); }, ), @@ -1181,10 +1077,7 @@ void main() { expect(position.pixels, greaterThan(0)); expect(_paintedBar(entries.last), findsOneWidget); - updateEntries(() { - selectedTerm = 'selected'; - filtered = entries.where((entry) => entry.term == selectedTerm).toList(); - }); + updateEntries(() => filtered = [entries.last]); // Check the first layout, not pumpAndSettle: a later unrelated rebuild // must not be required to recover cards culled using an obsolete offset. await tester.pump(); @@ -1238,11 +1131,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); @@ -1324,11 +1217,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/calendar_block_test.dart b/test/calendar_block_test.dart index 49c7e5a..d8b3185 100644 --- a/test/calendar_block_test.dart +++ b/test/calendar_block_test.dart @@ -201,7 +201,7 @@ void main() { event, startsAt.add(const Duration(hours: 2)), ); - 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/deadline_cutoff_badge_test.dart b/test/deadline_cutoff_badge_test.dart new file mode 100644 index 0000000..4413343 --- /dev/null +++ b/test/deadline_cutoff_badge_test.dart @@ -0,0 +1,259 @@ +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'; +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( + agenda: AgendaController(MemoryAgendaStore(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.agenda, + 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 new file mode 100644 index 0000000..b73eb4e --- /dev/null +++ b/test/deadline_cutoff_integration_test.dart @@ -0,0 +1,290 @@ +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.agenda, + app.agenda.entries.single, + ), + child: const Text('打开详情'), + ), + ), + ), + ), + )); + 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 伪截止', 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..8d58318 --- /dev/null +++ b/test/deadline_cutoff_settings_test.dart @@ -0,0 +1,576 @@ +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'; +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 { + final _failedWrites = {}; + + @override + Future setString(String key, String value) async { + if (key == _key && _failedWrites.add(key)) { + 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(); +} + +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 target = find.byKey( + ValueKey(start ? 'deadline-cutoff-start' : 'deadline-cutoff-end'), + ); + await tester.ensureVisible(target); + await tester.tap(target); + await tester.pumpAndSettle(); +} + +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.pumpAndSettle(); + await tester.tapAt(position(minute, 60, rect.width * .4)); + 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(); + + 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.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); + 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('A dial confirms only its endpoint using the latest settings', ( + tester, + ) async { + final preferences = MemoryPreferences(); + final settings = _settings(preferences); + addTearDown(settings.dispose); + await settings.initialize(); + await _showCutoff(tester, settings); + await _selectEndpoint(tester, start: true); + await _chooseTime(tester, 21, 15); + final pickerElement = tester.element(find.byType(TimePickerDialog)); + await settings.setDeadlineCutoff( + const DeadlineCutoffWindow(enabled: true, endMinute: 600), + ); + await tester.pump(); + 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), findsNothing); + expect(find.text('21:15'), findsOneWidget); + + 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.endMinute, 3 * 60 + 45); + expect(settings.deadlineCutoff.value.enabled, isTrue); + expect( + jsonDecode(preferences.value(_key)!), + settings.deadlineCutoff.value.toJson(), + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + 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()); + }, + ); + } + + 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), 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, + ) 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); + } + }); + }); +} 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 fe0504c..03226fd 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', @@ -156,6 +156,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/event_info_bar_test.dart b/test/event_info_bar_test.dart new file mode 100644 index 0000000..8977282 --- /dev/null +++ b/test/event_info_bar_test.dart @@ -0,0 +1,255 @@ +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'; +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, { + AgendaKind kind = AgendaKind.event, +}) => AgendaEntry( + id: id, + title: id, + kind: kind, + 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 ? 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']!.paintStart, deadline.dueAt.subtract(fixedCardSpan)); + } + }); + + 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'), 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()); + }, + ); + + 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/event_timeline_style_test.dart b/test/event_timeline_style_test.dart new file mode 100644 index 0000000..259273a --- /dev/null +++ b/test/event_timeline_style_test.dart @@ -0,0 +1,187 @@ +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); + // 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)]) { + 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)); + } + // 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) { + 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 bands. + expect(channel(x, 20, 0), greaterThan(channel(88, 20, 0))); + } + } + expect(channel(111, 20, 3), 0); + await tester.pumpWidget(const SizedBox()); + }); + } + + 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 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)); + 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, + ) 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()); + }); + } +} 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/storage_compatibility_test.dart b/test/storage_compatibility_test.dart index 86277bd..31ffeef 100644 --- a/test/storage_compatibility_test.dart +++ b/test/storage_compatibility_test.dart @@ -180,7 +180,25 @@ 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({...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( () => AgendaEntry.fromJson({...legacy, 'tailDays': invalid}), diff --git a/test/timeline_cache_merge_test.dart b/test/timeline_cache_merge_test.dart new file mode 100644 index 0000000..9fb2355 --- /dev/null +++ b/test/timeline_cache_merge_test.dart @@ -0,0 +1,104 @@ +import 'package:bitree/agenda/model.dart'; +import 'package:bitree/agenda/ui/adjusted_deadline_painter.dart'; +import 'package:bitree/agenda/ui/event_timeline_entry.dart'; +import 'package:bitree/agenda/ui/timeline_entry.dart'; +import 'package:bitree/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final _now = DateTime.utc(2030, 1, 2, 8); + +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: ValueListenableBuilder( + valueListenable: content, + builder: (_, current, _) => current, + ), + ))), + )); + await tester.pumpAndSettle(); + return content; +} + +void main() { + testWidgets('Adjusted DDL reuses text but refreshes geometry and tap callback', ( + tester, + ) async { + final entry = AgendaEntry( + id: 'adjusted', title: '缓存作业', kind: AgendaKind.deadline, + startsAt: DateTime.utc(2030, 1, 2, 14), + deadlineCutoffWindow: const DeadlineCutoffWindow( + enabled: true, startMinute: 600, endMinute: 1080, + ), + ); + var tapped = 0; + Widget card(double realX, VoidCallback onTap) => AgendaTimelineEntry( + entry: entry, now: _now, barWidth: 180, deadlineWidth: 180, + adjustedGeometry: AdjustedDeadlineGeometry( + bodyLeft: 0, effectiveX: 180, realX: realX, + ), + onTap: onTap, + ); + final content = await _show(tester, card(200, () => tapped = 1)); + final title = tester.widget(find.text(entry.title)); + final state = tester.state(find.byType(AgendaTimelineEntry)); + 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( + find.byType(AdjustedDeadlineSurface), + ); + expect(surface.geometry.realX, 230); + final painter = tester.widget(find.byWidgetPredicate( + (widget) => widget is CustomPaint && + widget.foregroundPainter is AdjustedDeadlineMarkerPainter, + )); + expect((painter.foregroundPainter! as AdjustedDeadlineMarkerPainter).realX, 230); + await tester.tap(find.text(entry.title)); + await tester.pumpAndSettle(); + expect(tapped, 2); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('Event text cache is independent of time-span geometry', ( + tester, + ) async { + final entry = AgendaEntry( + id: 'event', title: '缓存日程', kind: AgendaKind.event, + startsAt: DateTime.utc(2030, 1, 2, 10), + endsAt: DateTime.utc(2030, 1, 2, 11), + ); + Widget card(AgendaEntry value, double endX) => EventTimelineEntry( + entry: value, now: _now, informationWidth: 180, + geometry: EventTimelineGeometry( + informationLeft: 0, startX: 180, endX: endX, + ), + onTap: () {}, + ); + final content = await _show(tester, card(entry, 190)); + final title = tester.widget(find.text(entry.title)); + 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 && + widget.painter is EventTimelinePainter), + ).painter! as EventTimelinePainter; + expect(painter().geometry.endX, 220); + final originalAlpha = painter().fill.a; + 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); + await tester.pumpWidget(const SizedBox()); + }); +}