From 7789fd5f77123b681225bf80d9b9adf8c03c9bdf Mon Sep 17 00:00:00 2001 From: so1ve <58381667+so1ve@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:44:10 +0800 Subject: [PATCH 1/2] test: align month and overdue palette assertions --- test/localization_test.dart | 8 +++----- test/system_palette_test.dart | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/test/localization_test.dart b/test/localization_test.dart index 311aaa6..932a3f7 100644 --- a/test/localization_test.dart +++ b/test/localization_test.dart @@ -66,12 +66,10 @@ void main() { }); test('The home month header does not repeat the month suffix', () async { - for (final (locale, expected) in [ - (const Locale('zh'), '10月'), - (const Locale('en'), 'October'), - ]) { + for (final locale in [const Locale('zh'), const Locale('en')]) { final l10n = await AppLocalizations.delegate.load(locale); - expect(l10n.monthHeader(localizedMonth(l10n, 10)), expected); + final month = localizedMonth(l10n, 10); + expect(l10n.monthHeader(month), month); } }); } diff --git a/test/system_palette_test.dart b/test/system_palette_test.dart index 7543669..83b477f 100644 --- a/test/system_palette_test.dart +++ b/test/system_palette_test.dart @@ -252,8 +252,8 @@ void main() { overdue, now.add(const Duration(days: 2)), ); - expect(overdueColors.fill, originalAgenda.last.fill); - expect(overdueColors.text, originalAgenda.last.text); + expect(overdueColors.fill, theme.colorScheme.errorContainer); + expect(overdueColors.text, theme.colorScheme.onErrorContainer); expect(overdueColors.accent, theme.colorScheme.error); expect( agendaTimelineColors( From 5347a4917b69bfed10f79c47d124bfa8394a3237 Mon Sep 17 00:00:00 2001 From: so1ve <58381667+so1ve@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:46:42 +0800 Subject: [PATCH 2/2] refactor(agenda): move deleted DDLs to recycle bin page --- docs/architecture.md | 4 +- docs/ui-design.md | 3 + lib/agenda/ui/calendar_screen.dart | 2 +- lib/agenda/ui/deleted_deadlines.dart | 572 ++++++++++++++++++++++----- lib/l10n/app_en.arb | 14 + lib/l10n/app_localizations.dart | 24 ++ lib/l10n/app_localizations_en.dart | 14 + lib/l10n/app_localizations_zh.dart | 14 + lib/l10n/app_zh.arb | 4 + test/agenda_swipe_test.dart | 47 ++- 10 files changed, 578 insertions(+), 120 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 6a3696d..f6cbd51 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,7 +40,7 @@ Android 小组件由 `TimetableWidget` 更新、由 `WidgetAgenda` 读取本地 `sync/SyncController` 只持有一轮同步的所选内容、进行状态和结果,不提供任务队列、逐项追加或任务历史。覆盖课表、考试安排、成绩、学分进度及乐学、新延河课堂、MOOC、大物实验 DDL。共享学校会话的读取顺序执行,MOOC 独立并行;开始前等待其他学校查询结束,取消等待不会中断那次查询。大物实验 DDL 在本轮课表读取结束后生成。日程导入在 `AgendaController` 内顺序合并,基于最新已保存数据构造结果,并在真正写入前再次检查请求是否已取消,避免并行平台互相覆盖或取消后写入等待中的结果。 -首页和日程页共用 `SyncButton`,菜单只包含「同步全部」和「自定义同步」,不承载本地数据维护。成绩和学分进度只在各自页面提供同步入口;已删除的 DDL 只在日程页头提供恢复入口,与删除后的原位撤销共同组成恢复流程。「同步全部」默认覆盖可用的课表、考试和 DDL 来源,跳过尚未登录的 MOOC;自定义抽屉按课表与 DDL 两组勾选,主动勾选 MOOC 时可登录。同步静默开始,按钮显示加载状态,再次点击才展开「同步进度」。该抽屉只显示本轮进度、各项结果、整轮取消和重试未完成内容;重试保留本轮指定的学期,返回、收起和下滑关闭不会取消。完成或失败统一反馈一次,抽屉已展开时直接更新列表,不叠加通知遮住操作。 +首页和日程页共用 `SyncButton`,菜单只包含「同步全部」和「自定义同步」,不承载本地数据维护。成绩和学分进度只在各自页面提供同步入口;日程页“更多”菜单中的“DDL 回收站”入口负责进入独立恢复页面,与删除后的原位撤销共同组成恢复流程。「同步全部」默认覆盖可用的课表、考试和 DDL 来源,跳过尚未登录的 MOOC;自定义抽屉按课表与 DDL 两组勾选,主动勾选 MOOC 时可登录。同步静默开始,按钮显示加载状态,再次点击才展开「同步进度」。该抽屉只显示本轮进度、各项结果、整轮取消和重试未完成内容;重试保留本轮指定的学期,返回、收起和下滑关闭不会取消。完成或失败统一反馈一次,抽屉已展开时直接更新列表,不叠加通知遮住操作。 启动时只恢复学校登录,不同步业务数据。恢复登录和后台同步不提供交互验证回调;遇到短信或图形验证时,在获取手机号、验证码图片或发送短信前停止相关读取,并保留「需要短信验证/需要图形验证」账号状态,独立平台继续同步。用户主动重试未完成的内容或查询时可完成验证码验证;取消验证则保留同一待验证状态。 @@ -56,7 +56,7 @@ Android 小组件由 `TimetableWidget` 更新、由 `WidgetAgenda` 读取本地 USTC 风控采集失败时遵循学校登录页的协议:仅在风控请求出现 HTTP/连接错误,或响应不是有效 token 时,将加密的 `risk_payload` 设为 `{"error":true}`,仍保留 `riskEngine=true` 并由学校继续认证。这不是登录成功状态;密码错误、短信验证、服务回调和登录会话检查照常执行。取消、凭据存储失败和程序错误不进入此处理。采集失败的技术原因只写入日志,不单独弹出内部接口地址。`/gate` 是公开页面配置的前端资源网关,不能直接据此改写独立风控服务地址。 -数据仍使用 `schedule.json`、`agenda.json`、`academics.json` 和原有 JSON 字段;凭据与 Cookie 仍使用原有系统安全存储。DDL 同步保留本地编辑、删除标记和已完成状态,并接收平台完成状态。删除 DDL 时先提供原位撤销;离开当前视图后,可从日程页头的「已删除的 DDL」批量恢复。恢复不会出现在同步菜单中。 +数据仍使用 `schedule.json`、`agenda.json`、`academics.json` 和原有 JSON 字段;凭据与 Cookie 仍使用原有系统安全存储。DDL 同步保留本地编辑、删除标记和已完成状态,并接收平台完成状态。删除 DDL 时先提供原位撤销;离开当前视图后,可从日程页“更多”菜单进入独立的“DDL 回收站”批量恢复。恢复页面按截止日期分组,使用懒加载列表和搜索应对持续增长的数据;单条恢复使用文字按钮,永久清空放在页面“更多”菜单中。恢复不会出现在同步菜单中。 四个 DDL 来源统一在自定义同步中选择,不再占据顶层菜单的四个入口。`SchoolController.importDeadlines` 的可选 `source` 用于所选学校平台,省略时依次处理乐学与新延河课堂,保留逐平台错误处理、取消和登录状态修复。MOOC 使用独立的账号和控制器,不进入学校认证流程。大物实验为本地操作。 diff --git a/docs/ui-design.md b/docs/ui-design.md index 4d41285..72b1fed 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -131,6 +131,9 @@ Android 端保存这些设置,在应用关闭后刷新时读取当前系统颜 日程列表以顶部的“未完成/已完成”分段选择切换内容,不同时使用横滑切页; 时间轴不显示完成状态选择。学期切换、列表/时间轴切换和已删除 DDL 入口集中在 页头的“更多”菜单,标题行只保留类型筛选,避免手机端并排图标难以点击。 +已删除 DDL 使用独立的全屏“DDL 回收站”,而不是在日程页内再叠一层可增长列表。 +“更多”菜单中的入口显示待恢复数量;页面按截止日期分组、支持搜索与懒加载,并把 +批量恢复固定为底部主操作。单条恢复使用明确的文字按钮,永久清空放在页面“更多”中。 条目横拖立即跟手:短滑露出可点击的操作按钮,长滑达到阈值后振动并显示松手提示。 左滑删除,右滑完成或恢复 DDL;松手前可以拖回取消。展开后再次拖动从当前位置继续, 点击条目先收起,点击外部、滚动或开始操作另一条也会收起。纵向和斜向拖动优先交给滚动, diff --git a/lib/agenda/ui/calendar_screen.dart b/lib/agenda/ui/calendar_screen.dart index 9d0e2f8..0abecf0 100644 --- a/lib/agenda/ui/calendar_screen.dart +++ b/lib/agenda/ui/calendar_screen.dart @@ -265,7 +265,7 @@ class _AgendaOverflowMenu extends StatelessWidget { leadingIcon: const Icon(Icons.restore_from_trash_outlined), trailingIcon: Text('$deletedDeadlineCount'), onPressed: deletedDeadlinesEnabled ? onOpenDeletedDeadlines : null, - child: Text(l10n.deletedDeadlinesTitle), + child: Text(l10n.deletedDeadlinesRecycleBin), ), const Divider(height: 9), for (final term in values) diff --git a/lib/agenda/ui/deleted_deadlines.dart b/lib/agenda/ui/deleted_deadlines.dart index d5fc719..fcefe9c 100644 --- a/lib/agenda/ui/deleted_deadlines.dart +++ b/lib/agenda/ui/deleted_deadlines.dart @@ -1,37 +1,46 @@ import 'package:flutter/material.dart'; +import '../../core/time.dart'; import '../../l10n/formats.dart'; import '../../l10n/l10n.dart'; import '../../ui/app_dialog.dart'; +import '../../ui/app_menu_anchor.dart'; import '../../ui/app_notice.dart'; +import '../../ui/app_page.dart'; import '../../ui/design.dart'; -import '../../ui/selection_controls.dart'; -import '../../ui/sheet_layout.dart'; import '../controller.dart'; import '../model.dart'; Future showDeletedDeadlines( BuildContext context, AgendaController controller, -) => showAppSheet( - context: context, - builder: (_) => _DeletedDeadlinesSheet(controller: controller), +) => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DeletedDeadlinesScreen(controller: controller), + ), ); -class _DeletedDeadlinesSheet extends StatefulWidget { - const _DeletedDeadlinesSheet({required this.controller}); +class DeletedDeadlinesScreen extends StatefulWidget { + const DeletedDeadlinesScreen({super.key, required this.controller}); final AgendaController controller; @override - State<_DeletedDeadlinesSheet> createState() => _DeletedDeadlinesSheetState(); + State createState() => _DeletedDeadlinesScreenState(); } -class _DeletedDeadlinesSheetState extends State<_DeletedDeadlinesSheet> { +class _DeletedDeadlinesScreenState extends State { final _selected = {}; + final _search = TextEditingController(); bool _restoring = false; bool _emptying = false; + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + Future _empty(List entries) async { final controller = widget.controller; if (_emptying || @@ -100,118 +109,469 @@ class _DeletedDeadlinesSheetState extends State<_DeletedDeadlinesSheet> { } } + void _setSelected(AgendaEntry entry, bool selected) { + setState(() { + if (selected) { + _selected.add(entry.id); + } else { + _selected.remove(entry.id); + } + }); + } + + void _toggleVisible(List entries) { + final ids = {for (final entry in entries) entry.id}; + setState(() { + if (ids.isNotEmpty && ids.every(_selected.contains)) { + _selected.removeAll(ids); + } else { + _selected.addAll(ids); + } + }); + } + @override Widget build(BuildContext context) => ListenableBuilder( listenable: widget.controller, - builder: (context, _) { - final l10n = context.l10n; - final controller = widget.controller; - final entries = controller.deletedDeadlines.toList() - ..sort((a, b) { - final time = b.startsAt.compareTo(a.startsAt); - return time == 0 ? a.id.compareTo(b.id) : time; - }); - final visibleIds = {for (final entry in entries) entry.id}; - final selectedEntries = entries - .where((entry) => _selected.contains(entry.id)) - .toList(); - final enabled = - !_restoring && !_emptying && controller.ready && !controller.busy; - - return SheetLayout( - header: SheetHeader( - title: Text(l10n.deletedDeadlinesTitle), - actions: const [SheetCloseButton()], - ), - contentPadding: appSheetListPadding, - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (entries.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.xxl), - child: Text( - l10n.deletedDeadlinesNone, - textAlign: TextAlign.center, - ), - ) - else ...[ - AppSelectionBar( - selectedCount: selectedEntries.length, - totalCount: entries.length, - summary: l10n.selectedItems(selectedEntries.length), - enabled: enabled, - onSelectAll: () => setState(() => _selected.addAll(visibleIds)), - onClear: () => setState(_selected.clear), + builder: (context, _) => _build(context), + ); + + Widget _build(BuildContext context) { + final l10n = context.l10n; + final controller = widget.controller; + final entries = controller.deletedDeadlines.toList() + ..sort((a, b) { + final time = b.startsAt.compareTo(a.startsAt); + return time == 0 ? a.id.compareTo(b.id) : time; + }); + final query = _search.text.trim().toLowerCase(); + final visible = query.isEmpty + ? entries + : entries.where((entry) => _matches(entry, query)).toList(); + final selectedEntries = entries + .where((entry) => _selected.contains(entry.id)) + .toList(); + final enabled = + !_restoring && !_emptying && controller.ready && !controller.busy; + final groups = >{}; + for (final entry in visible) { + groups.putIfAbsent(calendarDate(entry.startsAt), () => []).add(entry); + } + final rows = <({DateTime? day, AgendaEntry? entry})>[ + for (final group in groups.entries) ...[ + (day: group.key, entry: null), + for (final entry in group.value) (day: null, entry: entry), + ], + ]; + + return AppRoutePage( + title: l10n.deletedDeadlinesRecycleBin, + actions: [ + AppMenuAnchor( + menuChildren: [ + AppMenuItemButton( + leadingIcon: Icon( + Icons.delete_forever_rounded, + color: Theme.of(context).colorScheme.error, ), - const SizedBox(height: AppSpacing.xs), - for (final entry in entries) - AppSelectionTile( - key: ValueKey(entry.id), - value: _selected.contains(entry.id), - enabled: enabled, - title: Text( - localizedAgendaTitle(l10n, entry), - maxLines: 2, - overflow: TextOverflow.ellipsis, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + onPressed: enabled && entries.isNotEmpty + ? () => _empty(entries) + : null, + child: Text(l10n.deletedDeadlinesEmptyAction), + ), + ], + builder: (context, menu, isOpen) => IconButton( + tooltip: l10n.more, + onPressed: () => isOpen ? menu.close() : menu.open(), + icon: const Icon(Icons.more_horiz_rounded), + ), + ), + ], + child: entries.isEmpty + ? _EmptyRecoveryList( + icon: Icons.delete_sweep_outlined, + message: l10n.deletedDeadlinesNone, + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.lg, + AppSpacing.xs, ), - subtitle: Text(_entrySubtitle(context, entry)), - onChanged: (checked) => setState(() { - if (checked == true) { - _selected.add(entry.id); - } else { - _selected.remove(entry.id); - } - }), - trailing: IconButton( - tooltip: l10n.deletedDeadlinesRestoreAction, - onPressed: enabled ? () => _restore([entry]) : null, - icon: const Icon(Icons.restore_rounded, size: 20), + child: TextField( + controller: _search, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + hintText: l10n.deletedDeadlinesSearchHint, + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: _search.text.isEmpty + ? null + : IconButton( + tooltip: l10n.clearSelection, + onPressed: () { + _search.clear(); + setState(() {}); + }, + icon: const Icon(Icons.close_rounded), + ), + filled: true, + fillColor: Theme.of(context) + .colorScheme + .surfaceContainerLow, + border: OutlineInputBorder( + borderRadius: AppCorners.control, + borderSide: BorderSide.none, + ), + ), ), ), - ], - ], - ), - actions: entries.isEmpty - ? null - : SheetActionBar.actions( - alignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton.icon( - style: TextButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.error, + _RecoverySelectionBar( + selectedCount: selectedEntries.length, + totalCount: entries.length, + visible: visible, + selected: _selected, + enabled: enabled, + onToggleVisible: () => _toggleVisible(visible), + onClear: () => setState(_selected.clear), + ), + Expanded( + child: visible.isEmpty + ? _EmptyRecoveryList( + icon: Icons.search_off_rounded, + message: l10n.deletedDeadlinesNoMatches, + ) + : ListView.builder( + padding: const EdgeInsets.only(bottom: AppSpacing.lg), + itemCount: rows.length, + itemBuilder: (context, index) { + final row = rows[index]; + if (row.entry case final entry?) { + return _DeletedDeadlineTile( + key: ValueKey(entry.id), + entry: entry, + selected: _selected.contains(entry.id), + enabled: enabled, + onChanged: (selected) => + _setSelected(entry, selected), + onRestore: () => _restore([entry]), + ); + } + return _RecoveryDateHeader(date: row.day!); + }, + ), + ), + DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + border: Border( + top: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), ), - onPressed: enabled ? () => _empty(entries) : null, - icon: const Icon(Icons.delete_forever_rounded, size: 18), - label: Text(l10n.deletedDeadlinesEmptyAction), ), - FilledButton.icon( - onPressed: enabled && selectedEntries.isNotEmpty - ? () => _restore(selectedEntries) - : null, - icon: const Icon(Icons.restore_rounded, size: 18), - label: Text( - selectedEntries.length == entries.length - ? l10n.deletedDeadlinesRestoreAll - : l10n.deletedDeadlinesRestoreSelected, + child: Padding( + padding: appSheetActionPadding, + child: FilledButton.icon( + onPressed: enabled && selectedEntries.isNotEmpty + ? () => _restore(selectedEntries) + : null, + icon: _restoring + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.restore_rounded), + label: Text( + selectedEntries.length == entries.length + ? l10n.deletedDeadlinesRestoreAll + : l10n.deletedDeadlinesRestoreSelected, + ), ), ), - ], + ), + ], + ), + ); + } + + bool _matches(AgendaEntry entry, String query) { + final l10n = context.l10n; + final source = entry.source?.localizedLabel(l10n) ?? l10n.deadlineManual; + final term = entry.term == null ? '' : localizedTerm(l10n, entry.term!); + return [ + localizedAgendaTitle(l10n, entry), + localizedAgendaNotes(l10n, entry), + entry.course, + source, + term, + ].join(' ').toLowerCase().contains(query); + } +} + +class _RecoverySelectionBar extends StatelessWidget { + const _RecoverySelectionBar({ + required this.selectedCount, + required this.totalCount, + required this.visible, + required this.selected, + required this.enabled, + required this.onToggleVisible, + required this.onClear, + }); + + final int selectedCount; + final int totalCount; + final List visible; + final Set selected; + final bool enabled; + final VoidCallback onToggleVisible; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final allVisibleSelected = + visible.isNotEmpty && + visible.every((entry) => selected.contains(entry.id)); + final noneVisibleSelected = + visible.isEmpty || + visible.every((entry) => !selected.contains(entry.id)); + return Padding( + padding: const EdgeInsets.fromLTRB(AppSpacing.sm, 0, AppSpacing.md, 0), + child: Row( + children: [ + Checkbox( + value: allVisibleSelected + ? true + : noneVisibleSelected + ? false + : null, + tristate: true, + onChanged: enabled && visible.isNotEmpty + ? (_) => onToggleVisible() + : null, + ), + Expanded( + child: InkWell( + borderRadius: AppCorners.control, + onTap: enabled && visible.isNotEmpty ? onToggleVisible : null, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + child: Row( + children: [ + Text( + l10n.selectAll, + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(width: AppSpacing.md), + Flexible( + child: Text( + l10n.deletedDeadlinesSelectionCount( + selectedCount, + totalCount, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), ), - ); - }, + ), + ), + TextButton( + onPressed: enabled && selectedCount > 0 ? onClear : null, + child: Text(l10n.clearSelection), + ), + ], + ), + ); + } +} + +class _RecoveryDateHeader extends StatelessWidget { + const _RecoveryDateHeader({required this.date}); + + final DateTime date; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.lg, + AppSpacing.lg, + AppSpacing.xs, + ), + child: Text( + localizedAgendaDate(context.l10n, date), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), ); +} + +class _DeletedDeadlineTile extends StatelessWidget { + const _DeletedDeadlineTile({ + super.key, + required this.entry, + required this.selected, + required this.enabled, + required this.onChanged, + required this.onRestore, + }); + + final AgendaEntry entry; + final bool selected; + final bool enabled; + final ValueChanged onChanged; + final VoidCallback onRestore; - String _entrySubtitle(BuildContext context, AgendaEntry entry) { + @override + Widget build(BuildContext context) { final l10n = context.l10n; - final source = [ - entry.source?.localizedLabel(l10n) ?? l10n.deadlineManual, + final scheme = Theme.of(context).colorScheme; + final source = entry.source?.localizedLabel(l10n) ?? l10n.deadlineManual; + final metadata = [ + source, if (entry.course.isNotEmpty) entry.course, - ].join(' '); - final time = - '${localizedAgendaDate(l10n, entry.startsAt)} ' - '${localizedAgendaTime(l10n, entry)}'; - return '$source\n$time'; + ].join(' · '); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), + child: Material( + color: selected + ? scheme.primaryContainer.withValues(alpha: .48) + : Colors.transparent, + borderRadius: AppCorners.control, + child: InkWell( + borderRadius: AppCorners.control, + onTap: enabled ? () => onChanged(!selected) : null, + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xs, + AppSpacing.xs, + AppSpacing.sm, + AppSpacing.sm, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Checkbox( + value: selected, + onChanged: enabled + ? (value) => onChanged(value ?? false) + : null, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: AppSpacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + localizedAgendaTitle(l10n, entry), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + const SizedBox(width: AppSpacing.md), + Text( + localizedAgendaTime(l10n, entry), + textAlign: TextAlign.end, + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + color: scheme.onSurfaceVariant, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xxs), + Row( + children: [ + Expanded( + child: Text( + metadata, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: scheme.onSurfaceVariant), + ), + ), + Tooltip( + message: l10n.deletedDeadlinesRestoreAction, + child: TextButton( + onPressed: enabled ? onRestore : null, + style: TextButton.styleFrom( + minimumSize: const Size(56, 36), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + ), + ), + child: Text(l10n.deletedDeadlinesRestoreAction), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); } } + +class _EmptyRecoveryList extends StatelessWidget { + const _EmptyRecoveryList({required this.icon, required this.message}); + + final IconData icon; + final String message; + + @override + Widget build(BuildContext context) => Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 40, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: AppSpacing.md), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 635fdf3..de81cea 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -781,6 +781,20 @@ } }, "deletedDeadlinesTitle": "Deleted DDLs", + "deletedDeadlinesRecycleBin": "DDL recycle bin", + "deletedDeadlinesSearchHint": "Search titles, courses, or sources", + "deletedDeadlinesNoMatches": "No matching DDLs", + "deletedDeadlinesSelectionCount": "{selected} of {total} selected", + "@deletedDeadlinesSelectionCount": { + "placeholders": { + "selected": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, "deletedDeadlinesNone": "No deleted DDLs", "deletedDeadlinesRestoreAll": "Restore all", "deletedDeadlinesRestoreAction": "Restore", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 9ca281e..256145f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1704,6 +1704,30 @@ abstract class AppLocalizations { /// **'Deleted DDLs'** String get deletedDeadlinesTitle; + /// No description provided for @deletedDeadlinesRecycleBin. + /// + /// In en, this message translates to: + /// **'DDL recycle bin'** + String get deletedDeadlinesRecycleBin; + + /// No description provided for @deletedDeadlinesSearchHint. + /// + /// In en, this message translates to: + /// **'Search titles, courses, or sources'** + String get deletedDeadlinesSearchHint; + + /// No description provided for @deletedDeadlinesNoMatches. + /// + /// In en, this message translates to: + /// **'No matching DDLs'** + String get deletedDeadlinesNoMatches; + + /// No description provided for @deletedDeadlinesSelectionCount. + /// + /// In en, this message translates to: + /// **'{selected} of {total} selected'** + String deletedDeadlinesSelectionCount(int selected, int total); + /// No description provided for @deletedDeadlinesNone. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 8ac22c8..1acbe8f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -997,6 +997,20 @@ class AppLocalizationsEn extends AppLocalizations { @override String get deletedDeadlinesTitle => 'Deleted DDLs'; + @override + String get deletedDeadlinesRecycleBin => 'DDL recycle bin'; + + @override + String get deletedDeadlinesSearchHint => 'Search titles, courses, or sources'; + + @override + String get deletedDeadlinesNoMatches => 'No matching DDLs'; + + @override + String deletedDeadlinesSelectionCount(int selected, int total) { + return '$selected of $total selected'; + } + @override String get deletedDeadlinesNone => 'No deleted DDLs'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 9be1e40..2edbfba 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -945,6 +945,20 @@ class AppLocalizationsZh extends AppLocalizations { @override String get deletedDeadlinesTitle => '已删除的 DDL'; + @override + String get deletedDeadlinesRecycleBin => 'DDL 回收站'; + + @override + String get deletedDeadlinesSearchHint => '搜索标题、课程或来源'; + + @override + String get deletedDeadlinesNoMatches => '没有匹配的 DDL'; + + @override + String deletedDeadlinesSelectionCount(int selected, int total) { + return '已选 $selected / $total'; + } + @override String get deletedDeadlinesNone => '没有已删除的 DDL'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index ea3ce30..8370494 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -264,6 +264,10 @@ "clearDeadlinesUnavailable": "日程未加载或正在保存,请稍后重试", "clearDeadlinesDeleted": "已删除 {count} 条 DDL", "deletedDeadlinesTitle": "已删除的 DDL", + "deletedDeadlinesRecycleBin": "DDL 回收站", + "deletedDeadlinesSearchHint": "搜索标题、课程或来源", + "deletedDeadlinesNoMatches": "没有匹配的 DDL", + "deletedDeadlinesSelectionCount": "已选 {selected} / {total}", "deletedDeadlinesNone": "没有已删除的 DDL", "deletedDeadlinesRestoreAll": "全部恢复", "deletedDeadlinesRestoreAction": "恢复", diff --git a/test/agenda_swipe_test.dart b/test/agenda_swipe_test.dart index 391783d..5e067bc 100644 --- a/test/agenda_swipe_test.dart +++ b/test/agenda_swipe_test.dart @@ -62,7 +62,7 @@ void main() { Future openDeletedDeadlines(WidgetTester tester) async { await openAgendaMenu(tester); - await tester.tap(find.text('已删除的 DDL')); + await tester.tap(find.text('DDL 回收站')); await tester.pumpAndSettle(); } @@ -115,18 +115,17 @@ void main() { }, ); - testWidgets('Deleted deadlines can be restored from the agenda header', ( - tester, - ) async { + testWidgets('Deleted deadlines open in a recoverable page', (tester) async { final app = await showAgenda(tester, deleted: true); await openDeletedDeadlines(tester); - expect(find.text('已删除的 DDL'), findsOneWidget); + expect(find.text('DDL 回收站'), findsOneWidget); + expect(find.byType(BottomSheet), findsNothing); await tester.tap(find.byTooltip('恢复')); await tester.pumpAndSettle(); expect(app.agenda.entries.single.id, 'task'); expect(app.agenda.deletedDeadlines, isEmpty); await openAgendaMenu(tester); - expect(find.text('已删除的 DDL'), findsNothing); + expect(find.text('DDL 回收站'), findsNothing); expect(tester.takeException(), isNull); }); @@ -141,11 +140,33 @@ void main() { ); expect(find.byTooltip('更多').hitTestable(), findsOneWidget); await openDeletedDeadlines(tester); - expect(find.text('已删除的 DDL'), findsOneWidget); + expect(find.text('DDL 回收站'), findsOneWidget); expect(tester.takeException(), isNull); }, ); + testWidgets('Deleted DDL recovery can search by title', (tester) async { + await showAgenda(tester, count: 3, deleted: true); + await openDeletedDeadlines(tester); + + await tester.enterText(find.byType(TextField), '作业 1'); + await tester.pumpAndSettle(); + final results = find.byType(ListView); + expect( + find.descendant(of: results, matching: find.text('作业 1')), + findsOneWidget, + ); + expect( + find.descendant(of: results, matching: find.text('第三章作业')), + findsNothing, + ); + expect( + find.descendant(of: results, matching: find.text('作业 2')), + findsNothing, + ); + expect(tester.takeException(), isNull); + }); + testWidgets( 'Deleted DDL recovery supports select all, clear, and partial restore', (tester) async { @@ -154,16 +175,16 @@ void main() { await tester.tap(find.byType(Checkbox).first); await tester.pump(); - expect(find.text('已选 3 项'), findsOneWidget); + expect(find.text('已选 3 / 3'), findsOneWidget); expect(find.text('全部恢复'), findsOneWidget); await tester.tap(find.text('清空')); await tester.pump(); - expect(find.text('已选 0 项'), findsOneWidget); + expect(find.text('已选 0 / 3'), findsOneWidget); await tester.tap(find.text('作业 1')); await tester.pump(); - expect(find.text('已选 1 项'), findsOneWidget); + expect(find.text('已选 1 / 3'), findsOneWidget); await tester.tap(find.text('恢复所选')); await tester.pumpAndSettle(); @@ -182,6 +203,8 @@ void main() { final app = await showAgenda(tester, count: 3, deleted: true); await openDeletedDeadlines(tester); + await tester.tap(find.byTooltip('更多')); + await tester.pumpAndSettle(); await tester.tap(find.text('清空回收站')); await tester.pumpAndSettle(); expect(find.text('清空回收站?'), findsOneWidget); @@ -190,6 +213,8 @@ void main() { await tester.pumpAndSettle(); expect(app.agenda.deletedDeadlines, hasLength(3)); + await tester.tap(find.byTooltip('更多')); + await tester.pumpAndSettle(); await tester.tap(find.text('清空回收站')); await tester.pumpAndSettle(); await tester.tap(find.text('永久删除')); @@ -198,7 +223,7 @@ void main() { expect(app.agenda.deletedDeadlines, isEmpty); expect(find.text('已清空 3 条 DDL'), findsOneWidget); await openAgendaMenu(tester); - expect(find.text('已删除的 DDL'), findsNothing); + expect(find.text('DDL 回收站'), findsNothing); expect(tester.takeException(), isNull); }, );