From 773654a1e938e45db23752f3ce05eaaf212f0566 Mon Sep 17 00:00:00 2001 From: so1ve <58381667+so1ve@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:40:51 +0800 Subject: [PATCH 01/10] refactor(ui): finish comprehensive interaction audit Unify responsive layouts, sheet workflows, navigation, feedback, and local update behavior while preserving existing data flows. --- docs/architecture.md | 4 +- lib/academics/ui/credit_targets_dialog.dart | 22 ++- lib/agenda/ui/agenda_detail.dart | 14 +- lib/agenda/ui/agenda_editor.dart | 8 +- lib/agenda/ui/agenda_list_tile.dart | 18 +- lib/agenda/ui/agenda_operations.dart | 7 +- lib/agenda/ui/clear_deadlines_sheet.dart | 29 ++- lib/agenda/ui/deleted_deadlines.dart | 5 +- lib/app/ui/add_menu.dart | 18 +- lib/app/ui/app_screen.dart | 201 +++++++++++--------- lib/app/ui/logs_screen.dart | 43 ++++- lib/app/ui/settings_screen.dart | 7 +- lib/app/ui/tools_screen.dart | 8 + lib/l10n/app_en.arb | 8 + lib/l10n/app_localizations.dart | 48 +++++ lib/l10n/app_localizations_en.dart | 28 +++ lib/l10n/app_localizations_zh.dart | 24 +++ lib/l10n/app_zh.arb | 8 + lib/schedule/ui/course_detail.dart | 2 + lib/schedule/ui/course_editor.dart | 2 + lib/schedule/ui/schedule_menu.dart | 3 + lib/schedule/ui/timetable_grid.dart | 21 +- lib/schedule/ui/week_range_dialog.dart | 24 ++- lib/school/ui/school_account.dart | 6 +- lib/school/ui/school_screen.dart | 2 +- lib/sync/controller.dart | 5 +- test/add_menu_test.dart | 75 ++++++++ test/agenda_swipe_test.dart | 12 +- test/app_navigation_test.dart | 11 ++ test/dialog_layout_test.dart | 32 ++++ test/local_failure_feedback_test.dart | 185 ++++++++++++++++++ test/responsive_shell_test.dart | 121 ++++++++++++ test/settings_interaction_test.dart | 195 +++++++++++++++++++ test/support/test_app.dart | 8 +- test/sync_button_test.dart | 28 +++ test/timetable_grid_menu_test.dart | 25 ++- test/update_dialog_test.dart | 6 +- 37 files changed, 1111 insertions(+), 152 deletions(-) create mode 100644 test/local_failure_feedback_test.dart create mode 100644 test/responsive_shell_test.dart create mode 100644 test/settings_interaction_test.dart diff --git a/docs/architecture.md b/docs/architecture.md index d59f7b0..5da8bde 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,7 +29,7 @@ flowchart LR `sync/SyncController` 只持有一轮同步的所选内容、进行状态和结果,不提供任务队列、逐项追加或任务历史。覆盖课表、考试安排、成绩、学分进度及乐学、新延河课堂、MOOC、大物实验 DDL。共享学校会话的读取顺序执行,MOOC 独立并行;开始前等待其他学校查询结束,取消等待不会中断那次查询。大物实验 DDL 在本轮课表读取结束后生成。日程导入在 `AgendaController` 内顺序合并,基于最新已保存数据构造结果,并在真正写入前再次检查请求是否已取消,避免并行平台互相覆盖或取消后写入等待中的结果。 -首页和日程页共用 `SyncButton`,菜单只有「同步全部」「自定义同步」和「已删除的 DDL」,同步操作与删除恢复操作之间有一条分隔线。「同步全部」默认覆盖可用来源,跳过尚未登录的 MOOC;自定义抽屉按课表与学业、DDL 两组勾选,主动勾选 MOOC 时可登录。同步静默开始,按钮显示加载状态,再次点击才展开「同步进度」。该抽屉只显示本轮进度、各项结果、整轮取消和重试未完成内容;重试保留本轮指定的学期,返回、收起和下滑关闭不会取消。完成或失败统一反馈一次,抽屉已展开时直接更新列表,不叠加通知遮住操作。 +首页和日程页共用 `SyncButton`,菜单只包含「同步全部」和「自定义同步」,不承载本地数据维护。成绩和学分进度只在各自页面提供同步入口;已删除的 DDL 只在日程页头提供恢复入口,与删除后的原位撤销共同组成恢复流程。「同步全部」默认覆盖可用的课表、考试和 DDL 来源,跳过尚未登录的 MOOC;自定义抽屉按课表与 DDL 两组勾选,主动勾选 MOOC 时可登录。同步静默开始,按钮显示加载状态,再次点击才展开「同步进度」。该抽屉只显示本轮进度、各项结果、整轮取消和重试未完成内容;重试保留本轮指定的学期,返回、收起和下滑关闭不会取消。完成或失败统一反馈一次,抽屉已展开时直接更新列表,不叠加通知遮住操作。 启动时只恢复学校登录,不同步业务数据。恢复登录和后台同步不提供交互验证回调;遇到短信或图形验证时,在获取手机号、验证码图片或发送短信前停止相关读取,并保留「需要短信验证/需要图形验证」账号状态,独立平台继续同步。用户主动重试未完成的内容或查询时可完成验证码验证;取消验证则保留同一待验证状态。 @@ -43,7 +43,7 @@ flowchart LR USTC 风控采集失败时遵循学校登录页的协议:仅在风控请求出现 HTTP/连接错误,或响应不是有效 token 时,将加密的 `risk_payload` 设为 `{"error":true}`,仍保留 `riskEngine=true` 并由学校继续认证。这不是登录成功状态;密码错误、短信验证、服务回调和登录会话检查照常执行。取消、凭据存储失败和程序错误不进入此处理。采集失败的技术原因只写入日志,不单独弹出内部接口地址。`/gate` 是公开页面配置的前端资源网关,不能直接据此改写独立风控服务地址。 -数据仍使用 `schedule.json`、`agenda.json`、`academics.json` 和原有 JSON 字段;凭据与 Cookie 仍使用原有系统安全存储。DDL 同步保留本地编辑、删除标记和已完成状态,并接收平台完成状态。恢复已删除 DDL 是独立操作。 +数据仍使用 `schedule.json`、`agenda.json`、`academics.json` 和原有 JSON 字段;凭据与 Cookie 仍使用原有系统安全存储。DDL 同步保留本地编辑、删除标记和已完成状态,并接收平台完成状态。删除 DDL 时先提供原位撤销;离开当前视图后,可从日程页头的「已删除的 DDL」批量恢复。恢复不会出现在同步菜单中。 四个 DDL 来源统一在自定义同步中选择,不再占据顶层菜单的四个入口。`SchoolController.importDeadlines` 的可选 `source` 用于所选学校平台,省略时依次处理乐学与新延河课堂,保留逐平台错误处理、取消和登录状态修复。MOOC 使用独立的账号和控制器,不进入学校认证流程。大物实验为本地操作。 diff --git a/lib/academics/ui/credit_targets_dialog.dart b/lib/academics/ui/credit_targets_dialog.dart index a35a5d3..6757152 100644 --- a/lib/academics/ui/credit_targets_dialog.dart +++ b/lib/academics/ui/credit_targets_dialog.dart @@ -41,6 +41,7 @@ class _CreditTargetsDialogState extends State<_CreditTargetsDialog> { }; final _newCategory = TextEditingController(); bool _saving = false; + String? _error; @override void dispose() { @@ -62,7 +63,11 @@ class _CreditTargetsDialogState extends State<_CreditTargetsDialog> { Future _save() async { if (!_form.currentState!.validate()) return; - setState(() => _saving = true); + setState(() { + _saving = true; + _error = null; + }); + final error = context.l10n.operationSaveFailed; final record = widget.controller.record; final saved = await widget.controller.save( record.withCreditTargets( @@ -78,7 +83,10 @@ class _CreditTargetsDialogState extends State<_CreditTargetsDialog> { if (saved) { Navigator.pop(context); } else { - setState(() => _saving = false); + setState(() { + _saving = false; + _error = error; + }); } } @@ -154,6 +162,16 @@ class _CreditTargetsDialogState extends State<_CreditTargetsDialog> { ), ], ), + if (_error != null) ...[ + const SizedBox(height: AppSpacing.lg), + Semantics( + liveRegion: true, + child: Text( + _error!, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + ], ], ), ), diff --git a/lib/agenda/ui/agenda_detail.dart b/lib/agenda/ui/agenda_detail.dart index 79625ba..4a65e8c 100644 --- a/lib/agenda/ui/agenda_detail.dart +++ b/lib/agenda/ui/agenda_detail.dart @@ -4,6 +4,7 @@ import '../../app/controller.dart'; import '../../l10n/formats.dart'; import '../../l10n/l10n.dart'; import '../../schedule/model.dart'; +import '../../ui/app_notice.dart'; import '../../ui/external_link.dart'; import '../../ui/sheet_layout.dart'; import '../model.dart'; @@ -204,7 +205,18 @@ Future showAgendaEntry( : null, onPressed: controller.agenda.busy ? null - : () => toggleAgendaDone(controller.agenda, current), + : () async { + final saved = await toggleAgendaDone( + controller.agenda, + current, + ); + if (!saved && context.mounted) { + showAppNotice( + context, + context.l10n.operationSaveFailed, + ); + } + }, icon: Icon( current.done ? Icons.undo_rounded : Icons.check_rounded, size: 20, diff --git a/lib/agenda/ui/agenda_editor.dart b/lib/agenda/ui/agenda_editor.dart index 322f441..499e477 100644 --- a/lib/agenda/ui/agenda_editor.dart +++ b/lib/agenda/ui/agenda_editor.dart @@ -144,6 +144,7 @@ class _AgendaEditorState extends State<_AgendaEditor> { _saving = true; _error = null; }); + final l10n = context.l10n; final success = await widget.controller.agenda.save( AgendaEntry( id: @@ -169,12 +170,14 @@ class _AgendaEditorState extends State<_AgendaEditor> { } else { setState(() { _saving = false; + _error = l10n.operationSaveFailed; }); } } Future _delete() async { setState(() => _saving = true); + final error = context.l10n.operationDeleteFailed; final success = await deleteAgendaEntry( context, widget.controller, @@ -184,7 +187,10 @@ class _AgendaEditorState extends State<_AgendaEditor> { if (success) { Navigator.pop(context); } else { - setState(() => _saving = false); + setState(() { + _saving = false; + _error = error; + }); } } diff --git a/lib/agenda/ui/agenda_list_tile.dart b/lib/agenda/ui/agenda_list_tile.dart index e3c3527..bcf2f92 100644 --- a/lib/agenda/ui/agenda_list_tile.dart +++ b/lib/agenda/ui/agenda_list_tile.dart @@ -4,6 +4,7 @@ import '../../app/controller.dart'; import '../../l10n/formats.dart'; import '../../l10n/l10n.dart'; import '../../schedule/model.dart'; +import '../../ui/app_notice.dart'; import '../../ui/swipe_action_tile.dart'; import '../model.dart'; import 'agenda_detail.dart'; @@ -25,8 +26,17 @@ class AgendaListTile extends StatelessWidget { final DateTime now; final VoidCallback? onTap; - Future _complete({bool haptic = false}) => - toggleAgendaDone(controller.agenda, entry, haptic: haptic); + Future _complete(BuildContext context, {bool haptic = false}) async { + final saved = await toggleAgendaDone( + controller.agenda, + entry, + haptic: haptic, + ); + if (!saved && context.mounted) { + showAppNotice(context, context.l10n.operationSaveFailed); + } + return saved; + } @override Widget build(BuildContext context) { @@ -69,7 +79,7 @@ class AgendaListTile extends StatelessWidget { icon: entry.done ? Icons.undo_rounded : Icons.check_rounded, background: scheme.primaryContainer, foreground: scheme.onPrimaryContainer, - onInvoke: _complete, + onInvoke: () => _complete(context), ) : null, rightAction: TileSwipeAction( @@ -105,7 +115,7 @@ class AgendaListTile extends StatelessWidget { shape: const CircleBorder(), onChanged: controller.agenda.busy ? null - : (_) => _complete(haptic: true), + : (_) => _complete(context, haptic: true), ), ) : Icon( diff --git a/lib/agenda/ui/agenda_operations.dart b/lib/agenda/ui/agenda_operations.dart index 473fdc2..2deaf7a 100644 --- a/lib/agenda/ui/agenda_operations.dart +++ b/lib/agenda/ui/agenda_operations.dart @@ -42,13 +42,18 @@ Future deleteAgendaEntry( } final deleted = await controller.agenda.delete(entry); if (!overlay.mounted) return deleted; - if (!deleted) return false; + if (!deleted) { + showOverlayNotice(overlay, l10n.operationDeleteFailed); + return false; + } Future restore() async { final restored = await controller.agenda.restore(entry); if (!overlay.mounted) return; if (restored) { showOverlayNotice(overlay, l10n.agendaRestored(entry.title)); + } else { + showOverlayNotice(overlay, l10n.operationRestoreFailed); } } diff --git a/lib/agenda/ui/clear_deadlines_sheet.dart b/lib/agenda/ui/clear_deadlines_sheet.dart index 4d6f01c..e323b7b 100644 --- a/lib/agenda/ui/clear_deadlines_sheet.dart +++ b/lib/agenda/ui/clear_deadlines_sheet.dart @@ -39,6 +39,7 @@ class _ClearDeadlinesSheet extends StatefulWidget { class _ClearDeadlinesSheetState extends State<_ClearDeadlinesSheet> { final _selected = {}; bool _saving = false; + String? _error; Future _clear( Set scopes, @@ -50,13 +51,20 @@ class _ClearDeadlinesSheetState extends State<_ClearDeadlinesSheet> { showAppNotice(context, context.l10n.clearDeadlinesUnavailable); return; } - setState(() => _saving = true); + setState(() { + _saving = true; + _error = null; + }); + final error = context.l10n.operationDeleteFailed; final cleared = await agenda.clearDeadlines( scopes, expectedIds: expectedIds, ); if (!mounted) return; - setState(() => _saving = false); + setState(() { + _saving = false; + _error = cleared ? null : error; + }); if (cleared) Navigator.pop(context, expectedIds.length); } @@ -87,9 +95,14 @@ class _ClearDeadlinesSheetState extends State<_ClearDeadlinesSheet> { totalCount: DeadlineClearScope.values.length, summary: l10n.clearDeadlinesSelection(scopes.length, count), enabled: enabled, - onSelectAll: () => - setState(() => _selected.addAll(DeadlineClearScope.values)), - onClear: () => setState(_selected.clear), + onSelectAll: () => setState(() { + _selected.addAll(DeadlineClearScope.values); + _error = null; + }), + onClear: () => setState(() { + _selected.clear(); + _error = null; + }), ), for (final scope in DeadlineClearScope.values) AppSelectionTile( @@ -107,9 +120,15 @@ class _ClearDeadlinesSheetState extends State<_ClearDeadlinesSheet> { } else { _selected.remove(scope); } + _error = null; }) : null, ), + if (_error case final error?) + Padding( + padding: const EdgeInsets.only(top: AppSpacing.sm), + child: Text(error, style: TextStyle(color: scheme.error)), + ), const SizedBox(height: AppSpacing.md), Text( l10n.clearDeadlinesWarning, diff --git a/lib/agenda/ui/deleted_deadlines.dart b/lib/agenda/ui/deleted_deadlines.dart index 1825e37..2fd3f16 100644 --- a/lib/agenda/ui/deleted_deadlines.dart +++ b/lib/agenda/ui/deleted_deadlines.dart @@ -44,7 +44,10 @@ class _DeletedDeadlinesSheetState extends State<_DeletedDeadlinesSheet> { _restoring = false; if (saved) _selected.removeAll(restoredIds); }); - if (!saved) return; + if (!saved) { + showAppNotice(context, context.l10n.operationRestoreFailed); + return; + } final l10n = context.l10n; final message = switch (entries.length) { diff --git a/lib/app/ui/add_menu.dart b/lib/app/ui/add_menu.dart index 804797f..dec08ab 100644 --- a/lib/app/ui/add_menu.dart +++ b/lib/app/ui/add_menu.dart @@ -8,6 +8,11 @@ import '../../schedule/ui/course_editor.dart'; import '../../ui/app_menu_anchor.dart'; import '../controller.dart'; +bool _canAddCourse(AppController controller) => !controller.timetable.busy; + +bool _canAddAgenda(AppController controller) => + controller.agenda.ready && !controller.agenda.busy; + class AddMenu extends StatelessWidget { const AddMenu({ super.key, @@ -31,15 +36,16 @@ class AddMenu extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; - final agendaEnabled = controller.agenda.ready && !controller.agenda.busy; + final canAddCourse = _canAddCourse(controller); + final canAddAgenda = _canAddAgenda(controller); return AppMenuAnchor( controller: menuController, onClose: onClose, menuChildren: [ AppMenuItemButton( - onPressed: controller.timetable.busy - ? null - : () => editCourse(context, controller, selection: selection), + onPressed: canAddCourse + ? () => editCourse(context, controller, selection: selection) + : null, leadingIcon: const Icon(Icons.school_outlined), child: Text(l10n.addCourse), ), @@ -48,7 +54,7 @@ class AddMenu extends StatelessWidget { (AgendaKind.deadline, l10n.addDeadline, Icons.flag_outlined), ]) AppMenuItemButton( - onPressed: agendaEnabled + onPressed: canAddAgenda ? () => editAgenda( context, controller, @@ -80,7 +86,7 @@ class AddButton extends StatelessWidget { date: date, builder: (context, menu, isOpen) => IconButton( tooltip: context.l10n.add, - onPressed: controller.timetable.busy || controller.agenda.busy + onPressed: !_canAddCourse(controller) && !_canAddAgenda(controller) ? null : () => isOpen ? menu.close() : menu.open(), icon: const Icon(Icons.add_rounded), diff --git a/lib/app/ui/app_screen.dart b/lib/app/ui/app_screen.dart index d4aa912..707aa22 100644 --- a/lib/app/ui/app_screen.dart +++ b/lib/app/ui/app_screen.dart @@ -10,15 +10,25 @@ import '../../schedule/ui/home_screen.dart'; import '../../ui/app_notice.dart'; import '../../ui/app_page.dart'; import '../../ui/campus_time_builder.dart'; -import '../../updates/ui/update_check.dart'; +import '../../updates/ui/update_check.dart' as updates; import '../controller.dart'; import 'settings_screen.dart'; import 'tools_screen.dart'; +typedef AppUpdateChecker = Future Function( + BuildContext context, { + bool silent, +}); + class AppScreen extends StatefulWidget { - const AppScreen({super.key, required this.controller}); + const AppScreen({ + super.key, + required this.controller, + this.updateChecker = updates.checkForUpdates, + }); final AppController controller; + final AppUpdateChecker updateChecker; @override State createState() => _AppScreenState(); @@ -66,7 +76,7 @@ class _AppScreenState extends State { setState(() => _checkingUpdates = true); try { - await checkForUpdates(context, silent: silent); + await widget.updateChecker(context, silent: silent); } finally { if (mounted) setState(() => _checkingUpdates = false); } @@ -78,100 +88,107 @@ class _AppScreenState extends State { Widget _build(BuildContext context, DateTime now) { final l10n = context.l10n; - return Scaffold( - body: Stack( - fit: StackFit.expand, - children: [ - IndexedStack( - index: _tab, - children: [ - HomeScreen( - controller: widget.controller, - now: now, - active: _tab == 0, - ), - CalendarScreen( - controller: widget.controller, - now: now, - active: _tab == 1, - ), - ToolsScreen(controller: widget.controller), - SettingsScreen( - controller: widget.controller, - checkingForUpdates: _checkingUpdates, - onCheckForUpdates: () => _checkUpdates(), + return PopScope( + canPop: _tab == 0, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && _tab != 0) setState(() => _tab = 0); + }, + child: Scaffold( + body: Stack( + fit: StackFit.expand, + children: [ + IndexedStack( + index: _tab, + children: [ + HomeScreen( + controller: widget.controller, + now: now, + active: _tab == 0, + ), + CalendarScreen( + controller: widget.controller, + now: now, + active: _tab == 1, + ), + ToolsScreen(controller: widget.controller), + SettingsScreen( + controller: widget.controller, + checkingForUpdates: _checkingUpdates, + onCheckForUpdates: () => _checkUpdates(), + ), + ], + ), + ListenableBuilder( + listenable: Listenable.merge([ + widget.controller.school, + widget.controller.sync, + ]), + builder: (context, _) => + widget.controller.school.busy || widget.controller.sync.busy + ? Positioned( + top: MediaQuery.paddingOf(context).top, + left: 0, + right: 0, + child: const LinearProgressIndicator(minHeight: 3), + ) + : const SizedBox.shrink(), + ), + ], + ), + bottomNavigationBar: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border( + top: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, ), - ], - ), - ListenableBuilder( - listenable: Listenable.merge([ - widget.controller.school, - widget.controller.sync, - ]), - builder: (context, _) => - widget.controller.school.busy || widget.controller.sync.busy - ? Positioned( - top: MediaQuery.paddingOf(context).top, - left: 0, - right: 0, - child: const LinearProgressIndicator(minHeight: 3), - ) - : const SizedBox.shrink(), - ), - ], - ), - bottomNavigationBar: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - border: Border( - top: BorderSide( - color: Theme.of(context).colorScheme.outlineVariant, ), ), - ), - child: SafeArea( - top: false, - child: Center( - heightFactor: 1, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 640), - child: NavigationBar( - height: appNavigationBarHeight(context), - backgroundColor: Colors.transparent, - surfaceTintColor: Colors.transparent, - elevation: 0, - selectedIndex: _tab, - onDestinationSelected: (value) => setState(() => _tab = value), - destinations: [ - NavigationDestination( - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home_rounded), - label: l10n.navHome, - ), - NavigationDestination( - icon: _AgendaNavigationIcon( - agenda: widget.controller.agenda, - now: now, - icon: Icons.calendar_month_outlined, + child: SafeArea( + top: false, + child: Center( + heightFactor: 1, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: NavigationBar( + height: appNavigationBarHeight(context), + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + elevation: 0, + selectedIndex: _tab, + onDestinationSelected: (value) => + setState(() => _tab = value), + destinations: [ + NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home_rounded), + label: l10n.navHome, + ), + NavigationDestination( + icon: _AgendaNavigationIcon( + agenda: widget.controller.agenda, + now: now, + icon: Icons.calendar_month_outlined, + ), + selectedIcon: _AgendaNavigationIcon( + agenda: widget.controller.agenda, + now: now, + icon: Icons.calendar_month_rounded, + ), + label: l10n.navAgenda, + ), + NavigationDestination( + icon: Icon(Icons.grid_view_outlined), + selectedIcon: Icon(Icons.grid_view_rounded), + label: l10n.navTools, ), - selectedIcon: _AgendaNavigationIcon( - agenda: widget.controller.agenda, - now: now, - icon: Icons.calendar_month_rounded, + NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings_rounded), + label: l10n.navSettings, ), - label: l10n.navAgenda, - ), - NavigationDestination( - icon: Icon(Icons.grid_view_outlined), - selectedIcon: Icon(Icons.grid_view_rounded), - label: l10n.navTools, - ), - NavigationDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings_rounded), - label: l10n.navSettings, - ), - ], + ], + ), ), ), ), diff --git a/lib/app/ui/logs_screen.dart b/lib/app/ui/logs_screen.dart index b1bb973..6529c72 100644 --- a/lib/app/ui/logs_screen.dart +++ b/lib/app/ui/logs_screen.dart @@ -20,6 +20,8 @@ class _LogsScreenState extends State { String? _preview; bool _loading = true; bool _exporting = false; + String? _loadError; + String? _exportError; @override void initState() { @@ -34,12 +36,17 @@ class _LogsScreenState extends State { } Future _load() async { - setState(() => _loading = true); + setState(() { + _loading = true; + _loadError = null; + }); + final error = context.l10n.logsReadFailed; try { final preview = await AppDiagnostics.instance.recentLines(); if (mounted) setState(() => _preview = preview); } on Exception catch (failure, stack) { AppDiagnostics.instance.report('读取日志', failure, stack); + if (mounted) setState(() => _loadError = error); } finally { if (mounted) setState(() => _loading = false); } @@ -47,7 +54,10 @@ class _LogsScreenState extends State { Future _export() async { final l10n = context.l10n; - setState(() => _exporting = true); + setState(() { + _exporting = true; + _exportError = null; + }); try { final bytes = await AppDiagnostics.instance.export(); if (!mounted) return; @@ -61,6 +71,7 @@ class _LogsScreenState extends State { if (mounted && saved != null) showAppNotice(context, l10n.logsExported); } on Exception catch (failure, stack) { AppDiagnostics.instance.report('导出日志', failure, stack); + if (mounted) setState(() => _exportError = l10n.logsExportFailed); } finally { if (mounted) setState(() => _exporting = false); } @@ -84,9 +95,37 @@ class _LogsScreenState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(l10n.logsRecent, style: Theme.of(context).textTheme.bodySmall), + if (_loadError case final error?) ...[ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text( + error, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: _loading ? null : _load, + child: Text(l10n.retry), + ), + ], + ), + ], const SizedBox(height: 12), Expanded(child: _buildPreview(context)), const SizedBox(height: 16), + if (_exportError case final error?) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + error, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), FilledButton.icon( onPressed: _exporting ? null : _export, icon: _exporting diff --git a/lib/app/ui/settings_screen.dart b/lib/app/ui/settings_screen.dart index 39d1f30..2d5417c 100644 --- a/lib/app/ui/settings_screen.dart +++ b/lib/app/ui/settings_screen.dart @@ -12,6 +12,7 @@ import '../../sync/ui/sync.dart'; import '../../ui/app_dialog.dart'; import '../../ui/app_menu_anchor.dart'; import '../../ui/app_menu_trigger.dart'; +import '../../ui/app_notice.dart'; import '../../ui/app_page.dart'; import '../../ui/external_link.dart'; import '../../ui/section_label.dart'; @@ -57,7 +58,11 @@ class SettingsScreen extends StatelessWidget { confirmLabel: l10n.settingsDeleteSchedule, destructive: true, ); - if (confirmed) await controller.timetable.clear(); + if (!confirmed) return; + final cleared = await controller.timetable.clear(); + if (!cleared && context.mounted) { + showAppNotice(context, context.l10n.operationDeleteFailed); + } } Future _showAbout(BuildContext context) async { diff --git a/lib/app/ui/tools_screen.dart b/lib/app/ui/tools_screen.dart index 18121ff..3bd484d 100644 --- a/lib/app/ui/tools_screen.dart +++ b/lib/app/ui/tools_screen.dart @@ -7,6 +7,7 @@ import '../../academics/ui/grades_screen.dart'; import '../../classrooms/ui/classroom_screen.dart'; import '../../l10n/l10n.dart'; import '../../ui/app_page.dart'; +import '../../ui/section_label.dart'; import '../../wencui/ui/wencui_map_screen.dart'; import '../controller.dart'; @@ -21,7 +22,9 @@ class ToolsScreen extends StatelessWidget { return AppPage( title: Text(l10n.toolsTitle), child: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), children: [ + AppSectionHeader(l10n.academicTools), for (final tool in [ ( title: l10n.gradesAndAcademics, @@ -45,6 +48,7 @@ class ToolsScreen extends StatelessWidget { ), ]) ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 4), leading: Icon(tool.icon), title: Text(tool.title), trailing: const Icon(Icons.chevron_right_rounded), @@ -52,7 +56,10 @@ class ToolsScreen extends StatelessWidget { Navigator.of(context) .push(MaterialPageRoute(builder: (_) => tool.page)), ), + const SizedBox(height: 16), + AppSectionHeader(l10n.campusTools), ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 4), leading: const Icon(Icons.meeting_room_outlined), title: Text(l10n.emptyClassroomQuery), trailing: const Icon(Icons.chevron_right_rounded), @@ -63,6 +70,7 @@ class ToolsScreen extends StatelessWidget { ), ), ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 4), leading: const Icon(Icons.map_outlined), title: Text(l10n.wencuiMap), trailing: const Icon(Icons.chevron_right_rounded), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 35e7559..748a59d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -23,6 +23,9 @@ "refresh": "Refresh", "reload": "Reload", "confirm": "Confirm", + "operationSaveFailed": "Couldn’t save your changes. Please try again.", + "operationDeleteFailed": "Couldn’t delete this item. Please try again.", + "operationRestoreFailed": "Couldn’t restore this item. Please try again.", "view": "View", "showAll": "Show all", "edit": "Edit", @@ -446,6 +449,7 @@ "exportSchedule": "Export schedule", "exportScheduleEmpty": "No courses to export", "exportScheduleSuccess": "Schedule exported", + "exportScheduleFailed": "Couldn’t export the schedule. Please try again.", "exportScheduleSkipped": "Schedule exported; skipped {count} courses without a fixed time", "@exportScheduleSkipped": { "placeholders": { @@ -1008,6 +1012,8 @@ "schoolResend": "Resend", "schoolVerifying": "Verifying…", "toolsTitle": "Tools", + "academicTools": "Academics & planning", + "campusTools": "Campus lookup", "gradesAndAcademics": "Grades and academics", "gradeSimulation": "Grade projection", "creditProgress": "Degree credit progress", @@ -1019,6 +1025,8 @@ "exportingLogs": "Exporting…", "exportLogs": "Export logs", "reloadLogs": "Reload logs", + "logsReadFailed": "Couldn’t read logs. Please try again.", + "logsExportFailed": "Couldn’t export logs. Please try again.", "noLogs": "No logs", "logsExported": "Logs exported", "widgetGuideTitle": "Desktop agenda", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index fde8008..949157e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -236,6 +236,24 @@ abstract class AppLocalizations { /// **'Confirm'** String get confirm; + /// No description provided for @operationSaveFailed. + /// + /// In en, this message translates to: + /// **'Couldn’t save your changes. Please try again.'** + String get operationSaveFailed; + + /// No description provided for @operationDeleteFailed. + /// + /// In en, this message translates to: + /// **'Couldn’t delete this item. Please try again.'** + String get operationDeleteFailed; + + /// No description provided for @operationRestoreFailed. + /// + /// In en, this message translates to: + /// **'Couldn’t restore this item. Please try again.'** + String get operationRestoreFailed; + /// No description provided for @view. /// /// In en, this message translates to: @@ -1133,6 +1151,12 @@ abstract class AppLocalizations { /// **'Schedule exported'** String get exportScheduleSuccess; + /// No description provided for @exportScheduleFailed. + /// + /// In en, this message translates to: + /// **'Couldn’t export the schedule. Please try again.'** + String get exportScheduleFailed; + /// No description provided for @exportScheduleSkipped. /// /// In en, this message translates to: @@ -2454,6 +2478,18 @@ abstract class AppLocalizations { /// **'Tools'** String get toolsTitle; + /// No description provided for @academicTools. + /// + /// In en, this message translates to: + /// **'Academics & planning'** + String get academicTools; + + /// No description provided for @campusTools. + /// + /// In en, this message translates to: + /// **'Campus lookup'** + String get campusTools; + /// No description provided for @gradesAndAcademics. /// /// In en, this message translates to: @@ -2520,6 +2556,18 @@ abstract class AppLocalizations { /// **'Reload logs'** String get reloadLogs; + /// No description provided for @logsReadFailed. + /// + /// In en, this message translates to: + /// **'Couldn’t read logs. Please try again.'** + String get logsReadFailed; + + /// No description provided for @logsExportFailed. + /// + /// In en, this message translates to: + /// **'Couldn’t export logs. Please try again.'** + String get logsExportFailed; + /// No description provided for @noLogs. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c0d67d9..a0d4167 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -78,6 +78,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get confirm => 'Confirm'; + @override + String get operationSaveFailed => + 'Couldn’t save your changes. Please try again.'; + + @override + String get operationDeleteFailed => + 'Couldn’t delete this item. Please try again.'; + + @override + String get operationRestoreFailed => + 'Couldn’t restore this item. Please try again.'; + @override String get view => 'View'; @@ -619,6 +631,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get exportScheduleSuccess => 'Schedule exported'; + @override + String get exportScheduleFailed => + 'Couldn’t export the schedule. Please try again.'; + @override String exportScheduleSkipped(int count) { return 'Schedule exported; skipped $count courses without a fixed time'; @@ -1403,6 +1419,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get toolsTitle => 'Tools'; + @override + String get academicTools => 'Academics & planning'; + + @override + String get campusTools => 'Campus lookup'; + @override String get gradesAndAcademics => 'Grades and academics'; @@ -1436,6 +1458,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get reloadLogs => 'Reload logs'; + @override + String get logsReadFailed => 'Couldn’t read logs. Please try again.'; + + @override + String get logsExportFailed => 'Couldn’t export logs. Please try again.'; + @override String get noLogs => 'No logs'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 226696a..d9231c4 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -78,6 +78,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get confirm => '确定'; + @override + String get operationSaveFailed => '未能保存更改,请重试。'; + + @override + String get operationDeleteFailed => '未能删除,请重试。'; + + @override + String get operationRestoreFailed => '未能恢复,请重试。'; + @override String get view => '查看'; @@ -594,6 +603,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get exportScheduleSuccess => '已导出课表'; + @override + String get exportScheduleFailed => '导出课表失败,请重试。'; + @override String exportScheduleSkipped(int count) { return '已导出课表,跳过 $count 门时间待定的课程'; @@ -1344,6 +1356,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get toolsTitle => '工具'; + @override + String get academicTools => '学业与规划'; + + @override + String get campusTools => '校园查询'; + @override String get gradesAndAcademics => '成绩与学业'; @@ -1377,6 +1395,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get reloadLogs => '重新读取日志'; + @override + String get logsReadFailed => '日志读取失败,请重试。'; + + @override + String get logsExportFailed => '日志导出失败,请重试。'; + @override String get noLogs => '暂无日志'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 964ac83..e882ccc 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -23,6 +23,9 @@ "refresh": "刷新", "reload": "重新读取", "confirm": "确定", + "operationSaveFailed": "未能保存更改,请重试。", + "operationDeleteFailed": "未能删除,请重试。", + "operationRestoreFailed": "未能恢复,请重试。", "view": "查看", "showAll": "显示全部", "edit": "编辑", @@ -170,6 +173,7 @@ "exportSchedule": "导出课表", "exportScheduleEmpty": "没有可导出的课程", "exportScheduleSuccess": "已导出课表", + "exportScheduleFailed": "导出课表失败,请重试。", "exportScheduleSkipped": "已导出课表,跳过 {count} 门时间待定的课程", "desktopAgendaWidget": "桌面日程表", "exportIcs": "导出 ICS", @@ -389,6 +393,8 @@ "schoolResend": "重新发送", "schoolVerifying": "验证中…", "toolsTitle": "工具", + "academicTools": "学业与规划", + "campusTools": "校园查询", "gradesAndAcademics": "成绩与学业", "gradeSimulation": "成绩试算", "creditProgress": "毕业学分进度", @@ -400,6 +406,8 @@ "exportingLogs": "导出中…", "exportLogs": "导出日志", "reloadLogs": "重新读取日志", + "logsReadFailed": "日志读取失败,请重试。", + "logsExportFailed": "日志导出失败,请重试。", "noLogs": "暂无日志", "logsExported": "已导出日志", "widgetGuideTitle": "桌面日程表", diff --git a/lib/schedule/ui/course_detail.dart b/lib/schedule/ui/course_detail.dart index adbe18f..d03f5ec 100644 --- a/lib/schedule/ui/course_detail.dart +++ b/lib/schedule/ui/course_detail.dart @@ -405,5 +405,7 @@ Future _deleteCourse( if (success) { Navigator.pop(context); + } else { + showAppNotice(context, l10n.operationDeleteFailed); } } diff --git a/lib/schedule/ui/course_editor.dart b/lib/schedule/ui/course_editor.dart index f70e26c..1c84c95 100644 --- a/lib/schedule/ui/course_editor.dart +++ b/lib/schedule/ui/course_editor.dart @@ -116,6 +116,7 @@ class _CourseEditorState extends State<_CourseEditor> { _saving = true; _error = null; }); + final error = context.l10n.operationSaveFailed; final course = Course( id: widget.course?.stableId ?? @@ -151,6 +152,7 @@ class _CourseEditorState extends State<_CourseEditor> { } else { setState(() { _saving = false; + _error = error; }); } } diff --git a/lib/schedule/ui/schedule_menu.dart b/lib/schedule/ui/schedule_menu.dart index 33af5ad..5d43a57 100644 --- a/lib/schedule/ui/schedule_menu.dart +++ b/lib/schedule/ui/schedule_menu.dart @@ -59,6 +59,9 @@ class _ScheduleMenuState extends State { ); } on Exception catch (failure, stack) { AppDiagnostics.instance.report('导出课表', failure, stack); + if (mounted) { + showAppNotice(context, context.l10n.exportScheduleFailed); + } } finally { if (mounted) setState(() => _exporting = false); } diff --git a/lib/schedule/ui/timetable_grid.dart b/lib/schedule/ui/timetable_grid.dart index bfd2e92..e3eb2e9 100644 --- a/lib/schedule/ui/timetable_grid.dart +++ b/lib/schedule/ui/timetable_grid.dart @@ -476,13 +476,20 @@ class _PeriodLabel extends StatelessWidget { style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), ), const SizedBox(height: 2), - Text( - '${time.$1}\n${time.$2}', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 10, - height: 1.2, - color: scheme.onSurfaceVariant, + SizedBox( + width: double.infinity, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + '${time.$1}\n${time.$2}', + softWrap: false, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + height: 1.2, + color: scheme.onSurfaceVariant, + ), + ), ), ), ], diff --git a/lib/schedule/ui/week_range_dialog.dart b/lib/schedule/ui/week_range_dialog.dart index 332d5d1..324d510 100644 --- a/lib/schedule/ui/week_range_dialog.dart +++ b/lib/schedule/ui/week_range_dialog.dart @@ -50,6 +50,7 @@ class _WeekRangeDialogState extends State<_WeekRangeDialog> { late final _first = TextEditingController(text: '${widget.first}'); late final _last = TextEditingController(text: '${widget.last}'); bool _saving = false; + String? _error; @override void dispose() { @@ -84,14 +85,21 @@ class _WeekRangeDialogState extends State<_WeekRangeDialog> { ); final controller = widget.controller; if (controller != null) { - setState(() => _saving = true); + setState(() { + _saving = true; + _error = null; + }); + final error = context.l10n.operationSaveFailed; final saved = await controller.timetable.setWeekRange( first: range.first, last: range.last, ); if (!mounted) return; if (!saved) { - setState(() => _saving = false); + setState(() { + _saving = false; + _error = error; + }); return; } } @@ -199,6 +207,18 @@ class _WeekRangeDialogState extends State<_WeekRangeDialog> { const SizedBox(height: AppSpacing.lg), last, ], + if (_error != null) ...[ + const SizedBox(height: AppSpacing.lg), + Semantics( + liveRegion: true, + child: Text( + _error!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ], ], ), ), diff --git a/lib/school/ui/school_account.dart b/lib/school/ui/school_account.dart index ebe6805..bb5d885 100644 --- a/lib/school/ui/school_account.dart +++ b/lib/school/ui/school_account.dart @@ -21,7 +21,7 @@ class _SchoolAccountState extends State { bool _handlingAction = false; Future _perform(Future Function() action) async { - if (_handlingAction || widget.controller.timetable.busy) return; + if (_handlingAction) return; setState(() => _handlingAction = true); try { await action(); @@ -64,14 +64,14 @@ class _SchoolAccountState extends State { final hasAccount = controller.school.username != null; final needsVerification = controller.school.verificationRequired; final needsLogin = !hasAccount || controller.school.loginExpired; - final enabled = !_handlingAction && !controller.timetable.busy; + final enabled = !_handlingAction; final canLogin = enabled && !controller.school.busy; final onTap = needsLogin ? _login : needsVerification ? _verify : _signOut; - final canTap = needsLogin || needsVerification ? canLogin : enabled; + final canTap = needsLogin || needsVerification ? canLogin : false; final scheme = Theme.of(context).colorScheme; return ListTile( contentPadding: const EdgeInsets.symmetric(vertical: 4), diff --git a/lib/school/ui/school_screen.dart b/lib/school/ui/school_screen.dart index a1c2a70..0c25f86 100644 --- a/lib/school/ui/school_screen.dart +++ b/lib/school/ui/school_screen.dart @@ -13,7 +13,7 @@ Future openSchoolLogin( AppController controller, { DeadlineSource? source, }) async { - if (controller.timetable.busy || controller.school.busy) return null; + if (controller.school.busy) return null; return Navigator.of(context).push( MaterialPageRoute( builder: (_) => _SchoolScreen(controller: controller, source: source), diff --git a/lib/sync/controller.dart b/lib/sync/controller.dart index e5b2273..1eeddad 100644 --- a/lib/sync/controller.dart +++ b/lib/sync/controller.dart @@ -23,17 +23,18 @@ enum SyncTarget { const SyncTarget([this.source]); final DeadlineSource? source; + bool get usesSchoolSession => switch (this) { schedule || exams || grades || studyPlan || lexue || eclass => true, mooc || physicsLab => false, }; - bool get isAcademicRecord => this == grades || this == studyPlan; - SyncCategory get category => switch (this) { schedule || exams || grades || studyPlan => SyncCategory.schoolData, lexue || eclass || mooc || physicsLab => SyncCategory.deadlineData, }; + + bool get isAcademicRecord => this == grades || this == studyPlan; } enum SyncCategory { schoolData, deadlineData } diff --git a/test/add_menu_test.dart b/test/add_menu_test.dart index a51baaa..aa3759c 100644 --- a/test/add_menu_test.dart +++ b/test/add_menu_test.dart @@ -1,5 +1,9 @@ +import 'dart:async'; + import 'package:bitree/agenda/model.dart'; import 'package:bitree/agenda/ui/calendar_screen.dart'; +import 'package:bitree/schedule/model.dart'; +import 'package:bitree/schedule/store.dart'; import 'package:bitree/schedule/ui/home_screen.dart'; import 'package:bitree/ui/editor_sheet.dart'; import 'package:bitree/ui/theme.dart'; @@ -8,6 +12,20 @@ import 'package:flutter_test/flutter_test.dart'; import 'support/test_app.dart'; +class _BlockingScheduleStore extends MemoryScheduleStore { + _BlockingScheduleStore({required super.selected}); + + final started = Completer(); + final release = Completer(); + + @override + Future write(ScheduleLibrary library) async { + if (!started.isCompleted) started.complete(); + await release.future; + await super.write(library); + } +} + void main() { final now = DateTime.utc(2026, 10, 28, 12); @@ -137,4 +155,61 @@ void main() { ); } } + + testWidgets('One busy editor does not block unrelated add actions', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 844); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + final schedule = SemesterSchedule( + term: '2026-2027-1', + firstMonday: DateTime.utc(2026, 10, 26), + courses: const [], + ); + final store = _BlockingScheduleStore(selected: schedule); + final controller = TestAppController(scheduleStore: store); + await controller.initialize(); + final save = controller.timetable.setWeekRange(first: 1, last: 16); + await store.started.future; + + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: HomeScreen(controller: controller, now: now, active: false), + ), + ), + ); + await tester.pump(); + + expect(controller.timetable.busy, isTrue); + await tester.tap(find.byTooltip('添加')); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.widgetWithText(MenuItemButton, '添加课程')) + .onPressed, + isNull, + ); + expect( + tester + .widget(find.widgetWithText(MenuItemButton, '添加日程')) + .onPressed, + isNotNull, + ); + expect( + tester + .widget(find.widgetWithText(MenuItemButton, '添加 DDL')) + .onPressed, + isNotNull, + ); + expect(tester.takeException(), isNull); + + store.release.complete(); + await save; + await tester.pumpAndSettle(); + }); } diff --git a/test/agenda_swipe_test.dart b/test/agenda_swipe_test.dart index bf05bd3..68207f9 100644 --- a/test/agenda_swipe_test.dart +++ b/test/agenda_swipe_test.dart @@ -69,7 +69,6 @@ void main() { expect(app.agenda.deletedDeadlines.single.id, 'task'); expect(find.text('第三章作业'), findsNothing); expect(find.text('未完成 0'), findsOneWidget); - expect(find.byTooltip('已删除的 DDL'), findsOneWidget); await tester.tap(find.text('撤销')); await tester.pumpAndSettle(); expect(app.agenda.entries.single.id, 'task'); @@ -123,7 +122,7 @@ void main() { }); testWidgets( - 'The direct recovery entry fits a narrow screen with large text', + 'Deleted DDL recovery remains usable on a narrow screen with large text', (tester) async { await showAgenda( tester, @@ -141,7 +140,7 @@ void main() { ); testWidgets( - 'Deleted DDL recovery shares select-all, clear, and partial restore', + 'Deleted DDL recovery supports select all, clear, and partial restore', (tester) async { final app = await showAgenda(tester, count: 3, deleted: true); await tester.tap(find.byTooltip('已删除的 DDL')); @@ -167,13 +166,6 @@ void main() { 'task2', }); expect(find.text('已恢复“作业 1”'), findsOneWidget); - expect( - find.descendant( - of: find.byType(BottomSheet), - matching: find.text('作业 1'), - ), - findsNothing, - ); expect(tester.takeException(), isNull); }, ); diff --git a/test/app_navigation_test.dart b/test/app_navigation_test.dart index bfdc26f..8f9888f 100644 --- a/test/app_navigation_test.dart +++ b/test/app_navigation_test.dart @@ -36,8 +36,19 @@ void main() { expect(tester.widget(bar).selectedIndex, index); expect(find.text(content[index]).hitTestable(), findsOneWidget); expect(find.text('学习服务'), findsNothing); + if (index == 2) { + expect(find.text('学业与规划'), findsOneWidget); + expect(find.text('校园查询'), findsOneWidget); + } expect(tester.takeException(), isNull); } + + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(tester.widget(bar).selectedIndex, 0); + expect(find.text('同步全部').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); await tester.pump(); }); diff --git a/test/dialog_layout_test.dart b/test/dialog_layout_test.dart index 531f1d4..890e4f5 100644 --- a/test/dialog_layout_test.dart +++ b/test/dialog_layout_test.dart @@ -5,8 +5,10 @@ import 'package:bitree/schedule/ui/week_range_dialog.dart'; import 'package:bitree/school/ui/school_verification_dialog.dart'; import 'package:bitree/school/verification.dart'; import 'package:bitree/ui/dialog_layout.dart'; +import 'package:bitree/ui/external_link.dart'; import 'package:bitree/ui/theme.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; final _surface = find.byWidgetPredicate( @@ -245,4 +247,34 @@ void main() { expect(result, '1234'); expect(tester.takeException(), isNull); }); + + testWidgets('External link failures use a recoverable dialog', (tester) async { + const channel = MethodChannel('plugins.flutter.io/url_launcher'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => false); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + await _host( + tester, + const Size(390, 844), + (context) => TextButton( + onPressed: () => openExternalLink( + context, + Uri.parse('https://example.com'), + failureTitle: '无法打开链接', + ), + child: const Text('打开链接'), + ), + ); + + await tester.tap(find.text('打开链接')); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.byType(BottomSheet), findsNothing); + expect(find.text('无法打开链接'), findsOneWidget); + expect(find.textContaining('https://example.com'), findsOneWidget); + expect(tester.takeException(), isNull); + }); } diff --git a/test/local_failure_feedback_test.dart b/test/local_failure_feedback_test.dart new file mode 100644 index 0000000..8f9f4df --- /dev/null +++ b/test/local_failure_feedback_test.dart @@ -0,0 +1,185 @@ +import 'dart:io'; + +import 'package:bitree/academics/model.dart'; +import 'package:bitree/academics/ui/credit_targets_dialog.dart'; +import 'package:bitree/agenda/controller.dart'; +import 'package:bitree/agenda/model.dart'; +import 'package:bitree/agenda/ui/agenda_editor.dart'; +import 'package:bitree/agenda/ui/clear_deadlines_sheet.dart'; +import 'package:bitree/agenda/ui/deleted_deadlines.dart'; +import 'package:bitree/schedule/model.dart'; +import 'package:bitree/schedule/store.dart'; +import 'package:bitree/schedule/ui/course_editor.dart'; +import 'package:bitree/schedule/ui/week_range_dialog.dart'; +import 'package:bitree/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/test_app.dart'; + +class _FailingAgendaStore extends MemoryAgendaStore { + _FailingAgendaStore([super.entries]); + + @override + Future write(List entries) async { + throw const FileSystemException('agenda write failed'); + } +} + +class _FailingScheduleStore extends MemoryScheduleStore { + _FailingScheduleStore(SemesterSchedule schedule) + : super(schedules: [schedule]); + + @override + Future write(ScheduleLibrary library) async { + throw const FileSystemException('schedule write failed'); + } +} + +class _FailingAcademicStore extends MemoryAcademicStore { + @override + Future write(String account, AcademicRecord record) async { + throw const FileSystemException('academic write failed'); + } +} + +Future _host( + WidgetTester tester, + Future Function(BuildContext context) open, +) async { + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => open(context), + child: const Text('打开'), + ), + ), + ), + ), + ); + await tester.tap(find.text('打开')); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('Agenda save failure keeps the editor and its input visible', ( + tester, + ) async { + final app = TestAppController( + agenda: AgendaController(_FailingAgendaStore()), + ); + await app.initialize(); + await _host( + tester, + (context) => editAgenda(context, app, kind: AgendaKind.deadline), + ); + + await tester.enterText(find.byType(TextFormField).first, '保留的作业'); + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + expect(find.text('未能保存更改,请重试。'), findsOneWidget); + expect(find.text('保留的作业'), findsOneWidget); + expect(find.byType(BottomSheet), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('Course and week-range writes explain failures in place', ( + tester, + ) async { + final schedule = SemesterSchedule( + term: '2026-2027-1', + firstMonday: DateTime.utc(2026, 10, 26), + courses: const [], + ); + final app = TestAppController( + scheduleStore: _FailingScheduleStore(schedule), + ); + await app.initialize(); + await _host(tester, (context) => editCourse(context, app)); + + await tester.enterText(find.byType(TextFormField).first, '保留的课程'); + await tester.tap(find.text('保存课程')); + await tester.pumpAndSettle(); + expect(find.text('未能保存更改,请重试。'), findsOneWidget); + expect(find.text('保留的课程'), findsOneWidget); + + await tester.tap(find.byTooltip('关闭')); + await tester.pumpAndSettle(); + await _host(tester, (context) => manageTermWeeks(context, app)); + await tester.tap(find.widgetWithText(FilledButton, '确定')); + await tester.pumpAndSettle(); + expect(find.text('未能保存更改,请重试。'), findsOneWidget); + expect(find.byType(AlertDialog), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('Clear deadlines failure remains actionable in its sheet', ( + tester, + ) async { + final entry = AgendaEntry( + id: 'deadline', + title: '课程作业', + kind: AgendaKind.deadline, + startsAt: DateTime.utc(2026, 11, 2, 23, 59), + ); + final app = TestAppController( + agenda: AgendaController(_FailingAgendaStore([entry])), + ); + await app.initialize(); + await _host(tester, (context) => showClearDeadlines(context, app.agenda)); + + await tester.tap(find.byType(Checkbox).first); + await tester.pump(); + await tester.tap(find.widgetWithText(FilledButton, '删除 1 条 DDL')); + await tester.pumpAndSettle(); + + expect(find.text('未能删除,请重试。'), findsOneWidget); + expect(find.byType(BottomSheet), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('Restore failure keeps deleted DDL available for retry', ( + tester, + ) async { + final entry = AgendaEntry( + id: 'deleted', + title: '待恢复作业', + kind: AgendaKind.deadline, + startsAt: DateTime.utc(2026, 11, 2, 23, 59), + deleted: true, + ); + final app = TestAppController( + agenda: AgendaController(_FailingAgendaStore([entry])), + ); + await app.initialize(); + await _host(tester, (context) => showDeletedDeadlines(context, app.agenda)); + + await tester.tap(find.byTooltip('恢复')); + await tester.pumpAndSettle(); + + expect(find.text('未能恢复,请重试。'), findsOneWidget); + expect(find.text('待恢复作业'), findsOneWidget); + expect(app.agenda.deletedDeadlines.single.id, 'deleted'); + expect(tester.takeException(), isNull); + }); + + testWidgets('Credit target save failure remains visible and retryable', ( + tester, + ) async { + final app = TestAppController(academicStore: _FailingAcademicStore()); + await app.initialize(); + await app.academics.load('student'); + await _host(tester, (context) => editCreditTargets(context, app.academics)); + + await tester.tap(find.text('保存')); + await tester.pumpAndSettle(); + + expect(find.text('未能保存更改,请重试。'), findsOneWidget); + expect(find.byType(AlertDialog), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/responsive_shell_test.dart b/test/responsive_shell_test.dart new file mode 100644 index 0000000..2885f9a --- /dev/null +++ b/test/responsive_shell_test.dart @@ -0,0 +1,121 @@ +import 'package:bitree/agenda/model.dart'; +import 'package:bitree/app/ui/app_screen.dart'; +import 'package:bitree/schedule/model.dart'; +import 'package:bitree/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/test_app.dart'; + +void main() { + testWidgets( + 'The complete navigation shell remains usable on a narrow screen with large text', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 640); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + final schedule = SemesterSchedule( + term: '2026-2027-1', + firstMonday: DateTime.utc(2026, 8, 31), + courses: [ + Course( + id: 'course', + name: '概率论与数理统计', + weekday: 2, + startSection: 3, + endSection: 4, + weeks: [1, 2, 3, 4, 5, 6, 7, 8], + room: '文萃楼F704', + ), + ], + ); + final controller = TestAppController( + schedule: schedule, + initialWeek: 6, + entries: [ + AgendaEntry( + id: 'deadline', + title: '完成概率论章节练习', + kind: AgendaKind.deadline, + startsAt: DateTime.utc(2026, 11, 3, 23, 59), + notes: '提交到乐学平台', + ), + ], + ); + await controller.initialize(); + + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: const TextScaler.linear(1.8)), + child: child!, + ), + home: AppScreen( + controller: controller, + updateChecker: (_, {silent = false}) async {}, + ), + ), + ); + await tester.pump(); + + final navigation = find.byType(NavigationBar); + for (final label in ['首页', '日程', '工具', '设置']) { + final destination = find.descendant( + of: navigation, + matching: find.text(label), + ); + expect(destination.hitTestable(), findsOneWidget); + await tester.tap(destination); + await tester.pump(); + expect(tester.takeException(), isNull); + + switch (label) { + case '首页': + final sync = find.byTooltip('同步').hitTestable(); + expect(sync, findsOneWidget); + await tester.tap(sync); + await tester.pumpAndSettle(); + expect(find.text('同步全部').hitTestable(), findsOneWidget); + expect(find.text('自定义同步').hitTestable(), findsOneWidget); + expect(find.text('学分进度').hitTestable(), findsNothing); + await tester.tap(find.byTooltip('同步').hitTestable()); + await tester.pumpAndSettle(); + case '日程': + final add = find.byTooltip('添加').hitTestable(); + expect(add, findsOneWidget); + await tester.tap(add); + await tester.pumpAndSettle(); + expect(find.text('添加日程').hitTestable(), findsOneWidget); + expect(find.text('添加 DDL').hitTestable(), findsOneWidget); + await tester.tap(find.byTooltip('添加').hitTestable()); + await tester.pumpAndSettle(); + case '工具': + expect(find.text('成绩与学业').hitTestable(), findsOneWidget); + case '设置': + final settingsList = find.byType(Scrollable).hitTestable().first; + await tester.scrollUntilVisible( + find.text('本地日程'), + 240, + scrollable: settingsList, + ); + await tester.pumpAndSettle(); + expect(find.text('本地日程').hitTestable(), findsOneWidget); + await tester.scrollUntilVisible( + find.text('应用'), + 240, + scrollable: settingsList, + ); + await tester.pumpAndSettle(); + expect(find.text('应用').hitTestable(), findsOneWidget); + } + } + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + }, + ); +} diff --git a/test/settings_interaction_test.dart b/test/settings_interaction_test.dart new file mode 100644 index 0000000..d43dda7 --- /dev/null +++ b/test/settings_interaction_test.dart @@ -0,0 +1,195 @@ +import 'dart:async'; + +import 'package:bitree/app/ui/settings_screen.dart'; +import 'package:bitree/schedule/model.dart'; +import 'package:bitree/schedule/store.dart'; +import 'package:bitree/school/storage.dart'; +import 'package:bitree/ui/app_page.dart'; +import 'package:bitree/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/test_app.dart'; + +class _BlockingScheduleStore extends MemoryScheduleStore { + _BlockingScheduleStore({required super.selected}); + + final started = Completer(); + final release = Completer(); + + @override + Future write(ScheduleLibrary library) async { + if (!started.isCompleted) started.complete(); + await release.future; + await super.write(library); + } +} + +void main() { + testWidgets('The account row never hides sign-out behind an accidental tap', ( + tester, + ) async { + final controller = TestAppController( + schoolStorage: MemorySchoolStorage( + credentials: const SchoolCredentials( + username: '1120252797', + password: 'stored', + ), + ), + ); + await controller.initialize(); + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: SettingsScreen( + controller: controller, + checkingForUpdates: false, + onCheckForUpdates: () {}, + ), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('1120252797')); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsNothing); + expect(find.text('退出学校账号?'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('Local schedule writes do not disable school account actions', ( + tester, + ) async { + final schedule = SemesterSchedule( + term: '2026-2027-1', + firstMonday: DateTime.utc(2026, 10, 26), + courses: const [], + ); + final store = _BlockingScheduleStore(selected: schedule); + final controller = TestAppController( + scheduleStore: store, + schoolStorage: MemorySchoolStorage( + credentials: const SchoolCredentials( + username: '1120252797', + password: 'stored', + ), + ), + ); + await controller.initialize(); + final save = controller.timetable.setWeekRange(first: 1, last: 16); + await store.started.future; + + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: SettingsScreen( + controller: controller, + checkingForUpdates: false, + onCheckForUpdates: () {}, + ), + ), + ), + ); + await tester.pump(); + + expect( + tester + .widget( + find.widgetWithIcon(IconButton, Icons.logout_rounded), + ) + .onPressed, + isNotNull, + ); + + store.release.complete(); + await save; + await tester.pumpAndSettle(); + }); + + testWidgets('Local schedule writes do not block school login', ( + tester, + ) async { + final schedule = SemesterSchedule( + term: '2026-2027-1', + firstMonday: DateTime.utc(2026, 10, 26), + courses: const [], + ); + final store = _BlockingScheduleStore(selected: schedule); + final controller = TestAppController(scheduleStore: store); + await controller.initialize(); + final save = controller.timetable.setWeekRange(first: 1, last: 16); + await store.started.future; + + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: SettingsScreen( + controller: controller, + checkingForUpdates: false, + onCheckForUpdates: () {}, + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('登录学校账号')); + await tester.pumpAndSettle(); + + expect(find.byType(AppRoutePage), findsOneWidget); + expect(find.text('学号'), findsOneWidget); + + store.release.complete(); + await save; + await tester.pumpAndSettle(); + }); + + testWidgets('Settings stay usable on a narrow screen with large text', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 640); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + final controller = TestAppController( + schedule: SemesterSchedule( + term: '2026-2027-1', + firstMonday: DateTime.utc(2026, 10, 26), + courses: const [], + ), + ); + await controller.initialize(); + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: const TextScaler.linear(1.8)), + child: child!, + ), + home: Scaffold( + body: SettingsScreen( + controller: controller, + checkingForUpdates: false, + onCheckForUpdates: () {}, + ), + ), + ), + ); + await tester.pump(); + + for (final label in ['本地日程', '应用']) { + await tester.scrollUntilVisible( + find.text(label), + 300, + scrollable: find.byType(Scrollable).first, + ); + expect(find.text(label), findsOneWidget); + expect(tester.takeException(), isNull); + } + }); +} diff --git a/test/support/test_app.dart b/test/support/test_app.dart index 1252ee4..ca487bc 100644 --- a/test/support/test_app.dart +++ b/test/support/test_app.dart @@ -126,14 +126,18 @@ class TestAppController extends AppController { MoocStorage? moocStorage, SchoolService? school, SharedPreferencesAsync? preferences, + ScheduleStore? scheduleStore, + AcademicStore? academicStore, }) : super( - store: MemoryScheduleStore(schedules: schedules, selected: schedule), + store: + scheduleStore ?? + MemoryScheduleStore(schedules: schedules, selected: schedule), school: school ?? SchoolService(), schoolStorage: schoolStorage ?? MemorySchoolStorage(), preferences: preferences ?? MemoryPreferences(), moocStorage: moocStorage ?? MemoryMoocStorage(), agenda: agenda ?? AgendaController(MemoryAgendaStore(entries)), - academics: AcademicController(MemoryAcademicStore()), + academics: AcademicController(academicStore ?? MemoryAcademicStore()), ) { addTearDown(dispose); } diff --git a/test/sync_button_test.dart b/test/sync_button_test.dart index 6bad945..6303341 100644 --- a/test/sync_button_test.dart +++ b/test/sync_button_test.dart @@ -1,3 +1,5 @@ +import 'package:bitree/academics/ui/credit_progress_screen.dart'; +import 'package:bitree/academics/ui/grades_screen.dart'; import 'package:bitree/agenda/model.dart'; import 'package:bitree/app/ui/settings_screen.dart'; import 'package:bitree/sync/controller.dart'; @@ -57,6 +59,32 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('Academic records keep their page-specific sync actions', ( + tester, + ) async { + final app = TestAppController(); + await app.initialize(); + + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: GradesScreen(controller: app), + ), + ); + expect(find.byTooltip('同步成绩'), findsOneWidget); + expect(find.byTooltip('同步学分进度'), findsNothing); + + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: CreditProgressScreen(controller: app), + ), + ); + expect(find.byTooltip('同步学分进度'), findsOneWidget); + expect(find.byTooltip('同步成绩'), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets('Deleted deadlines are not duplicated in settings', ( tester, ) async { diff --git a/test/timetable_grid_menu_test.dart b/test/timetable_grid_menu_test.dart index 0e04c88..5cc53e9 100644 --- a/test/timetable_grid_menu_test.dart +++ b/test/timetable_grid_menu_test.dart @@ -13,8 +13,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'support/test_app.dart'; void main() { - const rowHeight = 64.0; - const gutterWidth = 64.0; + const defaultRowHeight = 64.0; + const defaultGutterWidth = 64.0; final monday = DateTime.utc(2026, 10, 26); final course = Course( id: 'course', @@ -42,6 +42,8 @@ void main() { WidgetTester tester, { VoidCallback? onOutsideTap, Size size = const Size(800, 1000), + double gutterWidth = defaultGutterWidth, + double rowHeight = defaultRowHeight, }) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = size; @@ -93,9 +95,12 @@ void main() { Offset cell(WidgetTester tester, int day, int section) { final grid = tester.getRect(find.byType(TimetableGrid)); - final width = (grid.width - gutterWidth) / 7; + final width = (grid.width - defaultGutterWidth) / 7; return grid.topLeft + - Offset(gutterWidth + (day + 0.5) * width, (section - 0.5) * rowHeight); + Offset( + defaultGutterWidth + (day + 0.5) * width, + (section - 0.5) * defaultRowHeight, + ); } Future openMenu( @@ -125,6 +130,18 @@ void main() { expect(tester.takeException(), isNull); } + testWidgets('Compact period labels fit their rows', (tester) async { + await showGrid( + tester, + size: const Size(320, 844), + gutterWidth: 30, + rowHeight: 53.2, + ); + + expect(find.text('08:00\n08:45'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + for (final sameCell in [true, false]) { testWidgets( 'Tapping ${sameCell ? 'the selected' : 'another'} empty cell only closes the menu', diff --git a/test/update_dialog_test.dart b/test/update_dialog_test.dart index 69e7b8f..cb825f5 100644 --- a/test/update_dialog_test.dart +++ b/test/update_dialog_test.dart @@ -205,16 +205,16 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('A failed download shows the URL fallback dialog', ( - tester, - ) async { + testWidgets('A failed download shows the URL fallback dialog', (tester) async { launchSucceeds = false; final release = _release(); await _show(tester, releases: [release]); await tester.tap(find.text('下载测试版')); await tester.pumpAndSettle(); expect(find.text('无法打开下载链接'), findsOneWidget); + expect(find.byType(UpdateDialog), findsOneWidget); expect(find.byType(AlertDialog), findsNWidgets(2)); + expect(find.byType(BottomSheet), findsNothing); expect(find.textContaining(release.downloadUrl.toString()), findsOneWidget); expect(tester.takeException(), isNull); }); From 56ead6e556d7f84ea7a35f8a641f625f9f37571c Mon Sep 17 00:00:00 2001 From: so1ve <58381667+so1ve@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:52:34 +0800 Subject: [PATCH 02/10] fix(android): make timetable widget fully interactive --- .../dev/so1ve/bitree/TimetableWidget.kt | 39 +++++++++++++++++-- .../src/main/res/layout/timetable_widget.xml | 7 ++-- .../src/main/res/layout/widget_content.xml | 6 +++ .../app/src/main/res/layout/widget_header.xml | 2 +- android/app/src/main/res/values/dimens.xml | 5 ++- docs/architecture.md | 5 +++ docs/ui-design.md | 6 +++ 7 files changed, 62 insertions(+), 8 deletions(-) create mode 100644 android/app/src/main/res/layout/widget_content.xml diff --git a/android/app/src/main/kotlin/dev/so1ve/bitree/TimetableWidget.kt b/android/app/src/main/kotlin/dev/so1ve/bitree/TimetableWidget.kt index 345f6e2..7342096 100644 --- a/android/app/src/main/kotlin/dev/so1ve/bitree/TimetableWidget.kt +++ b/android/app/src/main/kotlin/dev/so1ve/bitree/TimetableWidget.kt @@ -12,6 +12,8 @@ import android.text.TextPaint import android.view.View import android.widget.RemoteViews import androidx.core.widget.RemoteViewsCompat +import kotlin.math.max +import kotlin.math.roundToInt open class TimetableWidget : AppWidgetProvider() { override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) { @@ -79,6 +81,7 @@ open class TimetableWidget : AppWidgetProvider() { views.setTextViewText(R.id.widget_empty, content.emptyMessage) views.setEmptyView(R.id.widget_courses, R.id.widget_empty) views.setOnClickPendingIntent(R.id.widget_root, open) + views.setOnClickPendingIntent(R.id.widget_empty, open) // Collection views consume taps; forward them to the same app launch. views.setPendingIntentTemplate(R.id.widget_courses, open) @@ -94,7 +97,8 @@ open class TimetableWidget : AppWidgetProvider() { } val widthPx = widgetWidthPx(context, manager, id) - val items = entryItems(context, content.entries, widthPx, columns) + val heightPx = widgetContentMinHeightPx(context, manager, id) + val items = entryItems(context, content.entries, widthPx, heightPx, columns) RemoteViewsCompat.setRemoteAdapter( context, views, id, R.id.widget_courses, items, @@ -121,10 +125,31 @@ open class TimetableWidget : AppWidgetProvider() { } } + private fun widgetContentMinHeightPx( + context: Context, + manager: AppWidgetManager, + id: Int, + ): Float { + val resources = context.resources + val options = manager.getAppWidgetOptions(id) + val heightDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT) + val widgetHeight = if (heightDp > 0) { + heightDp * resources.displayMetrics.density + } else { + manager.getAppWidgetInfo(id)?.minHeight?.toFloat() ?: 0f + } + val chromeHeight = + 2 * resources.getDimension(R.dimen.widget_vertical_padding) + + resources.getDimension(R.dimen.widget_header_min_height) + + resources.getDimension(R.dimen.widget_header_spacing) + return max(0f, widgetHeight - chromeHeight) + } + private fun entryItems( context: Context, entries: List, widthPx: Float, + heightPx: Float, columns: Int, ): RemoteViewsCompat.RemoteCollectionItems { val resources = context.resources @@ -140,6 +165,10 @@ open class TimetableWidget : AppWidgetProvider() { val items = RemoteViewsCompat.RemoteCollectionItems.Builder() .setViewTypeCount(1) + if (entries.isEmpty()) return items.build() + + val content = RemoteViews(context.packageName, R.layout.widget_content) + content.removeAllViews(R.id.widget_content_stack) entries.chunked(columns).forEachIndexed { index, entriesInRow -> val row = RemoteViews(context.packageName, R.layout.widget_row) row.removeAllViews(R.id.widget_first_column) @@ -159,9 +188,13 @@ open class TimetableWidget : AppWidgetProvider() { entryView(context, entry, label), ) } - row.setOnClickFillInIntent(R.id.widget_row, Intent()) - items.addItem(index.toLong(), row) + content.addView(R.id.widget_content_stack, row) + } + with(RemoteViewsCompat) { + content.setViewMinimumHeight(R.id.widget_content_stack, heightPx.roundToInt()) } + content.setOnClickFillInIntent(R.id.widget_content_stack, Intent()) + items.addItem(0L, content) return items.build() } diff --git a/android/app/src/main/res/layout/timetable_widget.xml b/android/app/src/main/res/layout/timetable_widget.xml index b767917..6909b2d 100644 --- a/android/app/src/main/res/layout/timetable_widget.xml +++ b/android/app/src/main/res/layout/timetable_widget.xml @@ -6,8 +6,8 @@ android:orientation="vertical" android:paddingStart="@dimen/widget_padding" android:paddingEnd="@dimen/widget_padding" - android:paddingTop="8dp" - android:paddingBottom="8dp" + android:paddingTop="@dimen/widget_vertical_padding" + android:paddingBottom="@dimen/widget_vertical_padding" android:background="@drawable/widget_background" android:clipToOutline="true"> @@ -15,13 +15,14 @@ android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" - android:layout_marginTop="6dp"> + android:layout_marginTop="@dimen/widget_header_spacing"> + diff --git a/android/app/src/main/res/layout/widget_header.xml b/android/app/src/main/res/layout/widget_header.xml index 876080e..44ba972 100644 --- a/android/app/src/main/res/layout/widget_header.xml +++ b/android/app/src/main/res/layout/widget_header.xml @@ -4,7 +4,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" - android:minHeight="24dp" + android:minHeight="@dimen/widget_header_min_height" android:gravity="center_vertical"> - 20dp + 14dp 6dp 10dp + 8dp + 24dp + 6dp 6dp 8dp 11sp diff --git a/docs/architecture.md b/docs/architecture.md index 5da8bde..22eec5d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,6 +27,11 @@ flowchart LR `SchoolController` 负责登录状态、同步操作、请求繁忙状态和错误反馈。成功获取课表、DDL、考试或成绩后,交给相应功能控制器合并并保存。 +Android 小组件由 `TimetableWidget` 更新、由 `WidgetAgenda` 读取本地课表与日程。 +整个组件共享一个打开应用的 `PendingIntent`:根节点处理标题与边缘,集合模板处理 +课程、列表留白和空状态。课程行合并为单个可滚动集合项,并以列表可视高度作为最小 +高度,避免 `ListView` 吞掉空白区域点击,同时保留纵向滚动。 + `sync/SyncController` 只持有一轮同步的所选内容、进行状态和结果,不提供任务队列、逐项追加或任务历史。覆盖课表、考试安排、成绩、学分进度及乐学、新延河课堂、MOOC、大物实验 DDL。共享学校会话的读取顺序执行,MOOC 独立并行;开始前等待其他学校查询结束,取消等待不会中断那次查询。大物实验 DDL 在本轮课表读取结束后生成。日程导入在 `AgendaController` 内顺序合并,基于最新已保存数据构造结果,并在真正写入前再次检查请求是否已取消,避免并行平台互相覆盖或取消后写入等待中的结果。 首页和日程页共用 `SyncButton`,菜单只包含「同步全部」和「自定义同步」,不承载本地数据维护。成绩和学分进度只在各自页面提供同步入口;已删除的 DDL 只在日程页头提供恢复入口,与删除后的原位撤销共同组成恢复流程。「同步全部」默认覆盖可用的课表、考试和 DDL 来源,跳过尚未登录的 MOOC;自定义抽屉按课表与 DDL 两组勾选,主动勾选 MOOC 时可登录。同步静默开始,按钮显示加载状态,再次点击才展开「同步进度」。该抽屉只显示本轮进度、各项结果、整轮取消和重试未完成内容;重试保留本轮指定的学期,返回、收起和下滑关闭不会取消。完成或失败统一反馈一次,抽屉已展开时直接更新列表,不叠加通知遮住操作。 diff --git a/docs/ui-design.md b/docs/ui-design.md index a17be96..fd45f99 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -10,6 +10,7 @@ | 按钮、表单、下拉触发器、菜单、分段选择、标签、导航选中背景 | 10px 圆角 | | 对话框、底部弹层 | 20px 圆角;底部弹层只圆顶部 | | 课表紧凑块、小标记、提示标签 | 6px 圆角 | +| Android 桌面小组件外层 | 14dp 圆角 | | 完成状态、地图定位点、时钟表盘、图表 | 保留表达其含义的形状 | 普通操作的最小触摸目标为 48px。按钮使用同一字号、字重和内边距; @@ -61,6 +62,11 @@ 新增控件优先复用原生组件的主题;只有确实共用布局或交互时才提取组件。 +Android 桌面小组件整卡都是打开应用的点击区域,包括标题、课程卡、列表留白和 +空状态,不为不同区域绑定不同去向。课程列表仍可上下滚动;所有课程行放在同一个 +集合项中,该集合项的最小高度覆盖列表可视区,使下方留白也参与点击。外层背景与 +内容共同裁切到 14dp 圆角。 + ## 语言与文案 语言入口位于“设置 → 语言”,提供跟随系统、简体中文和 English,切换后立即生效并保留选择。语言名称使用各自的自称,不在中文界面中把 English 译成英文,也不在英文界面中把简体中文译成 Chinese。 From ca59897624d068585d01b3efb3a1f548f87ce90f Mon Sep 17 00:00:00 2001 From: so1ve <58381667+so1ve@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:56:50 +0800 Subject: [PATCH 03/10] fix(agenda): match swipe directions to user intent --- docs/architecture.md | 2 +- docs/ui-design.md | 2 +- lib/agenda/ui/agenda_list_tile.dart | 20 ++++++++--------- test/agenda_swipe_test.dart | 34 ++++++++++++++--------------- test/calendar_list_test.dart | 6 ++--- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 22eec5d..98176fc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -99,7 +99,7 @@ DDL 绘制与布局分离:右端为 `dueAt`,左侧渐隐,屏幕宽度为 ## 手势与布局 - `SwipePager` 统一鼠标与触摸分页,拖过半页即报告新页,程序切页不报告中间页。 -- `SwipeActionTile` 以明确的横向意图接管单条操作,不增加按住等待。短滑露出按钮,长滑达到阈值后松手执行;左滑完成或恢复 DDL,右滑删除。快速收回和反向拖回都可以取消。`TapRegion` 处理外部触摸收起,最近的纵向 `ScrollPosition` 处理滚动收起,不额外维护全局打开条目。滑出、按钮停靠与回弹共用动画,保存失败回到原位。行的增删尺寸动画由 `AnimatedSliverChildren` 统一处理。 +- `SwipeActionTile` 以明确的横向意图接管单条操作,不增加按住等待。短滑露出按钮,长滑达到阈值后松手执行;左滑删除,右滑完成或恢复 DDL。快速收回和反向拖回都可以取消。`TapRegion` 处理外部触摸收起,最近的纵向 `ScrollPosition` 处理滚动收起,不额外维护全局打开条目。滑出、按钮停靠与回弹共用动画,保存失败回到原位。行的增删尺寸动画由 `AnimatedSliverChildren` 统一处理。 - `PullToRevealList` 管理下拉手势和展开状态,`RevealScrollController` 在布局阶段同步内容高度与滚动位置。用户重新拖动时解除滚动保持,直接接管滚动。 - `AppMenuAnchor` 使用原生 `RawMenuAnchor` 管理焦点与菜单生命周期,统一面板的开合动效。菜单高度取触发器上下的实际可用空间。 - `SheetLayout`、`EditorSheet` 和主题共同约束弹层尺寸、固定操作区与可滚动内容。地图的缩放、绘制和命中测试由 `WencuiFloorMap` 管理。 diff --git a/docs/ui-design.md b/docs/ui-design.md index fd45f99..fa7773b 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -99,6 +99,6 @@ Android 桌面小组件整卡都是打开应用的点击区域,包括标题、 日程列表以顶部的“未完成/已完成”分段选择切换内容,不同时使用横滑切页。 条目横拖立即跟手:短滑露出可点击的操作按钮,长滑达到阈值后振动并显示松手提示。 -左滑完成或恢复 DDL,右滑删除;松手前可以拖回取消。展开后再次拖动从当前位置继续, +左滑删除,右滑完成或恢复 DDL;松手前可以拖回取消。展开后再次拖动从当前位置继续, 点击条目先收起,点击外部、滚动或开始操作另一条也会收起。纵向和斜向拖动优先交给滚动, 触控板手势不执行条目操作。 diff --git a/lib/agenda/ui/agenda_list_tile.dart b/lib/agenda/ui/agenda_list_tile.dart index bcf2f92..93778b1 100644 --- a/lib/agenda/ui/agenda_list_tile.dart +++ b/lib/agenda/ui/agenda_list_tile.dart @@ -68,7 +68,16 @@ class AgendaListTile extends StatelessWidget { ), child: SwipeActionTile( enabled: !controller.agenda.busy, - leftAction: entry.isDeadline + leftAction: TileSwipeAction( + label: l10n.agendaSwipeDelete, + releaseLabel: l10n.agendaSwipeDeleteRelease, + icon: Icons.delete_outline, + background: scheme.error, + foreground: scheme.onError, + onInvoke: () => + deleteAgendaEntry(context, controller, entry, confirm: false), + ), + rightAction: entry.isDeadline ? TileSwipeAction( label: entry.done ? l10n.agendaSwipeRestore @@ -82,15 +91,6 @@ class AgendaListTile extends StatelessWidget { onInvoke: () => _complete(context), ) : null, - rightAction: TileSwipeAction( - label: l10n.agendaSwipeDelete, - releaseLabel: l10n.agendaSwipeDeleteRelease, - icon: Icons.delete_outline, - background: scheme.error, - foreground: scheme.onError, - onInvoke: () => - deleteAgendaEntry(context, controller, entry, confirm: false), - ), child: Material( type: MaterialType.transparency, child: InkWell( diff --git a/test/agenda_swipe_test.dart b/test/agenda_swipe_test.dart index 68207f9..2230ea2 100644 --- a/test/agenda_swipe_test.dart +++ b/test/agenda_swipe_test.dart @@ -55,14 +55,14 @@ void main() { return app; } - testWidgets('A full right swipe deletes and undo restores the entry', ( + testWidgets('A full left swipe deletes and undo restores the entry', ( tester, ) async { final app = await showAgenda(tester); await swipeRow( tester, find.byKey(const ValueKey('task')), - const Offset(250, 0), + const Offset(-250, 0), ); await tester.pumpAndSettle(); expect(app.agenda.entries, isEmpty); @@ -80,13 +80,13 @@ void main() { }); testWidgets( - 'Full left swipes complete and restore through the status filter', + 'Full right swipes complete and restore through the status filter', (tester) async { final app = await showAgenda(tester); await swipeRow( tester, find.byKey(const ValueKey('task')), - const Offset(-250, 0), + const Offset(250, 0), ); await tester.pumpAndSettle(); expect(app.agenda.entries.single.done, isTrue); @@ -96,7 +96,7 @@ void main() { await swipeRow( tester, find.byKey(const ValueKey('task')).hitTestable(), - const Offset(-250, 0), + const Offset(250, 0), ); await tester.pumpAndSettle(); expect(app.agenda.entries.single.done, isFalse); @@ -183,13 +183,13 @@ void main() { kind: kind, ); await gesture.moveBy( - const Offset(24, 0), + const Offset(-24, 0), timeStamp: const Duration(milliseconds: 30), ); await tester.pump(); - expect(tester.getTopLeft(title).dx, closeTo(before.dx + 24, 1)); + expect(tester.getTopLeft(title).dx, closeTo(before.dx - 24, 1)); await gesture.moveBy( - const Offset(56, 0), + const Offset(-56, 0), timeStamp: const Duration(milliseconds: 100), ); await gesture.up(timeStamp: const Duration(milliseconds: 180)); @@ -211,14 +211,14 @@ void main() { ); } - testWidgets('A short left swipe exposes a working completion button', ( + testWidgets('A short right swipe exposes a working completion button', ( tester, ) async { final app = await showAgenda(tester); await swipeRow( tester, find.byKey(const ValueKey('task')), - const Offset(-90, 0), + const Offset(90, 0), ); await tester.pumpAndSettle(); expect(app.agenda.entries.single.done, isFalse); @@ -248,7 +248,7 @@ void main() { await tester.pumpAndSettle(); expect(tester.getTopLeft(row).dy, lessThan(before.dy - 80)); expect(app.agenda.entries.every((entry) => !entry.done), isTrue); - await swipeRow(tester, row, const Offset(80, 0)); + await swipeRow(tester, row, const Offset(-80, 0)); await tester.pumpAndSettle(); expect(find.text('删除'), findsOneWidget); await tester.sendEventToBinding( @@ -271,13 +271,13 @@ void main() { await swipeRow( tester, find.byKey(const ValueKey('task')), - const Offset(80, 0), + const Offset(-80, 0), ); await tester.pumpAndSettle(); final next = await startRowSwipe( tester, find.byKey(const ValueKey('task1')), - const Offset(-80, 0), + const Offset(80, 0), ); await next.up(timeStamp: const Duration(milliseconds: 240)); await tester.pumpAndSettle(); @@ -311,17 +311,17 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('A schedule has only a right swipe delete action', ( + 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 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 swipeRow(tester, row, const Offset(-90, 0)); await tester.pumpAndSettle(); await tester.tap(find.text('删除')); await tester.pumpAndSettle(); @@ -336,7 +336,7 @@ void main() { await swipeRow( tester, find.byKey(const ValueKey('task')), - const Offset(250, 0), + const Offset(-250, 0), ); await tester.pumpAndSettle(); await tester.fling(find.text('已删除“第三章作业”'), const Offset(240, 0), 1000); diff --git a/test/calendar_list_test.dart b/test/calendar_list_test.dart index 6d4ff7c..030c2c9 100644 --- a/test/calendar_list_test.dart +++ b/test/calendar_list_test.dart @@ -99,14 +99,14 @@ Future _open( } void main() { - testWidgets('The day drawer shares full left completion and right deletion', ( + testWidgets('The day drawer shares full right completion and left deletion', ( tester, ) async { final controller = await _open(tester, [_deadline(), _deadline(id: 2)]); await swipeRow( tester, find.byKey(const ValueKey('deadline1')), - const Offset(-240, 0), + const Offset(240, 0), ); await tester.pumpAndSettle(); expect( @@ -119,7 +119,7 @@ void main() { await swipeRow( tester, find.byKey(const ValueKey('deadline2')), - const Offset(240, 0), + const Offset(-240, 0), ); await tester.pumpAndSettle(); expect(controller.agenda.entries.single.id, 'deadline1'); From 4213e560877698eb499fa874c41744c2a15909a2 Mon Sep 17 00:00:00 2001 From: so1ve <58381667+so1ve@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:42:01 +0800 Subject: [PATCH 04/10] refactor(schedule): show conflicts by course and section --- lib/schedule/ui/conflicting_courses.dart | 219 ++++++++++++++++------- test/schedule_conflicts_test.dart | 119 ++++++++++++ 2 files changed, 273 insertions(+), 65 deletions(-) create mode 100644 test/schedule_conflicts_test.dart diff --git a/lib/schedule/ui/conflicting_courses.dart b/lib/schedule/ui/conflicting_courses.dart index 49d4f0b..64b2a2f 100644 --- a/lib/schedule/ui/conflicting_courses.dart +++ b/lib/schedule/ui/conflicting_courses.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import '../../app/controller.dart'; @@ -75,9 +77,12 @@ class ConflictingCourses extends StatelessWidget { Widget build(BuildContext context) { final l10n = context.l10n; final scheme = Theme.of(context).colorScheme; - final fill = isToday - ? Color.lerp(scheme.primaryContainer, scheme.primary, 0.12)! - : scheme.primaryContainer; + final firstSection = courses + .map((course) => course.startSection!) + .reduce(math.min); + final lastSection = courses + .map((course) => course.endSection!) + .reduce(math.max); final summary = courses .map( (course) => @@ -94,89 +99,121 @@ class ConflictingCourses extends StatelessWidget { triggerMode: TooltipTriggerMode.manual, message: summary, child: Material( - color: fill, + color: Colors.transparent, shape: RoundedRectangleBorder( borderRadius: AppCorners.compact, - side: isToday - ? BorderSide(color: scheme.primary, width: 1.5) - : BorderSide.none, + side: BorderSide( + color: isToday ? scheme.primary : scheme.outlineVariant, + width: isToday ? 1.5 : 1, + ), ), clipBehavior: Clip.antiAlias, child: InkWell( onTap: () => _selectCourse(context), child: LayoutBuilder( builder: (context, constraints) { - final scaler = MediaQuery.textScalerOf(context); - final titleHeight = scaler.scale(12) * 1.25; - final sectionHeight = scaler.scale(10) * 1.25; - final available = constraints.maxHeight - sectionHeight - 12; - final showSections = - available >= titleHeight + sectionHeight + 3; - final sectionSpace = showSections ? sectionHeight + 3 : 0; - final shownCount = - available >= (titleHeight * 2 + sectionSpace) * 2 + 4 - ? 2 - : 1; - final titleLines = - ((available - (shownCount - 1) * 4) / shownCount - - sectionSpace) ~/ - titleHeight; - + final showCount = constraints.maxHeight >= 32; return Padding( padding: const EdgeInsets.symmetric( - horizontal: 3, - vertical: 4, + horizontal: 2, + vertical: 2, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - l10n.courseOverlapCount(courses.length), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10, - height: 1.25, - fontWeight: FontWeight.w600, - color: scheme.primary, - ), - ), - const SizedBox(height: 4), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + if (showCount) ...[ + Row( children: [ - for (var i = 0; i < shownCount; i++) ...[ - if (i > 0) const SizedBox(height: 4), - Text( - courses[i].name, - textAlign: TextAlign.center, - maxLines: titleLines.clamp(1, 3), + Icon( + Icons.warning_amber_rounded, + size: 11, + color: scheme.primary, + ), + const SizedBox(width: 1), + Flexible( + child: Text( + l10n.courseOverlapCount(courses.length), + maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: 12, - height: 1.25, - fontWeight: FontWeight.w600, - color: scheme.onPrimaryContainer, + fontSize: 9, + height: 1, + fontWeight: FontWeight.w700, + color: scheme.primary, ), ), - if (showSections) ...[ - const SizedBox(height: 3), - Text( - localizedCourseSection(l10n, courses[i]), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10, - height: 1.25, - color: scheme.onPrimaryContainer, + ), + ], + ), + const SizedBox(height: 2), + ], + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + const rowHeight = 20.0; + final positionsAtStart = + constraints.maxHeight >= + courses.length * (rowHeight + 1); + if (!positionsAtStart) { + return Column( + children: [ + for (final course in courses) + Expanded( + child: _ConflictCourseRow( + course: course, + section: localizedCourseSection( + l10n, + course, + ), + ), + ), + ], + ); + } + + final span = lastSection - firstSection + 1; + final sorted = [...courses] + ..sort((first, second) { + final section = first.startSection!.compareTo( + second.startSection!, + ); + return section != 0 + ? section + : first.name.compareTo(second.name); + }); + final rows = []; + var nextTop = 0.0; + for (final course in sorted) { + final offset = + course.startSection! - firstSection; + final proposed = + offset / span * constraints.maxHeight; + final top = math + .max(proposed, nextTop) + .clamp( + 0.0, + constraints.maxHeight - rowHeight, + ) + .toDouble(); + nextTop = top + rowHeight + 1; + rows.add( + Positioned( + left: 0, + right: 0, + top: top, + height: rowHeight, + child: _ConflictCourseRow( + course: course, + section: localizedCourseSection( + l10n, + course, + ), ), ), - ], - ], - ], + ); + } + return Stack(children: rows); + }, ), ), ], @@ -191,3 +228,55 @@ class ConflictingCourses extends StatelessWidget { ); } } + +class _ConflictCourseRow extends StatelessWidget { + const _ConflictCourseRow({required this.course, required this.section}); + + final Course course; + final String section; + + @override + Widget build(BuildContext context) { + final colors = courseColors(context, course); + return DecoratedBox( + decoration: BoxDecoration( + color: colors.fill, + borderRadius: BorderRadius.circular(3), + border: Border(left: BorderSide(color: colors.accent, width: 2)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + course.name, + maxLines: 1, + style: TextStyle( + fontSize: 9, + height: 1.05, + fontWeight: FontWeight.w600, + color: colors.text, + ), + ), + const SizedBox(height: 1), + Text( + section, + maxLines: 1, + style: TextStyle( + fontSize: 8, + height: 1, + color: colors.secondaryText, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/test/schedule_conflicts_test.dart b/test/schedule_conflicts_test.dart new file mode 100644 index 0000000..8ae64d9 --- /dev/null +++ b/test/schedule_conflicts_test.dart @@ -0,0 +1,119 @@ +import 'package:bitree/agenda/calendar.dart'; +import 'package:bitree/schedule/layout.dart'; +import 'package:bitree/schedule/model.dart'; +import 'package:bitree/schedule/ui/conflicting_courses.dart'; +import 'package:bitree/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/test_app.dart'; + +void main() { + final date = DateTime.utc(2026, 10, 30); + + Course course( + String id, + String name, { + required int start, + required int end, + }) => Course( + id: id, + name: name, + weekday: DateTime.friday, + startSection: start, + endSection: end, + weeks: [1], + ); + + test( + 'Touching courses stay separate while chained overlaps remain one block', + () { + final touching = [ + course('first', '第一门', start: 1, end: 2), + course('second', '第二门', start: 3, end: 4), + ].map((course) => CalendarItem.course(course, date)).toList(); + + final separate = groupCalendarItems(touching, date); + expect(separate, hasLength(2)); + expect(separate.map((block) => block.start), [0, 2]); + + final chained = [ + course('a', '课程 A', start: 3, end: 5), + course('b', '课程 B', start: 4, end: 7), + course('c', '课程 C', start: 6, end: 8), + course('d', '课程 D', start: 8, end: 10), + ].map((course) => CalendarItem.course(course, date)).toList(); + + final grouped = groupCalendarItems(chained, date); + expect(grouped, hasLength(1)); + expect(grouped.single.start, 2); + expect(grouped.single.end, 10); + expect(grouped.single.items.map((item) => item.title), [ + '课程 A', + '课程 B', + '课程 C', + '课程 D', + ]); + }, + ); + + testWidgets( + 'A narrow conflict card lists every course and its exact sections', + (tester) async { + final courses = [ + course('a', '马克思主义基本原理', start: 3, end: 5), + course('b', '数据结构', start: 4, end: 7), + course('c', '概率论与数理统计', start: 6, end: 8), + course('d', '大学物理实验', start: 8, end: 10), + ]; + final app = TestAppController( + schedule: SemesterSchedule( + term: '2026-2027-1', + firstMonday: DateTime.utc(2026, 10, 26), + courses: courses, + ), + ); + await app.initialize(); + + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: Center( + child: SizedBox( + width: 48, + height: 448, + child: ConflictingCourses( + courses: courses, + controller: app, + isToday: true, + date: date, + ), + ), + ), + ), + ), + ); + + expect(find.text('4门'), findsOneWidget); + for (final item in courses) { + expect(find.text(item.name), findsOneWidget); + expect(find.text(item.sectionLabel), findsOneWidget); + } + expect(tester.takeException(), isNull); + + await tester.tap(find.byType(ConflictingCourses)); + await tester.pumpAndSettle(); + expect(find.text('选择课程'), findsOneWidget); + final sheet = find.byType(BottomSheet); + expect(sheet, findsOneWidget); + for (final item in courses) { + expect( + find.descendant(of: sheet, matching: find.text(item.name)), + findsOneWidget, + ); + } + expect(tester.takeException(), isNull); + }, + ); +} From fe2ffab3b8b0ca11647e40b4ac3654fd61be00a9 Mon Sep 17 00:00:00 2001 From: so1ve <58381667+so1ve@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:42:16 +0800 Subject: [PATCH 05/10] fix(school): keep startup verification silent --- lib/app/ui/app_screen.dart | 6 +-- lib/school/controller.dart | 7 ++- test/school_verification_test.dart | 71 ++++++++++++++---------------- 3 files changed, 42 insertions(+), 42 deletions(-) diff --git a/lib/app/ui/app_screen.dart b/lib/app/ui/app_screen.dart index 707aa22..264f628 100644 --- a/lib/app/ui/app_screen.dart +++ b/lib/app/ui/app_screen.dart @@ -55,12 +55,12 @@ class _AppScreenState extends State { _started = true; widget.controller.removeListener(_onReady); WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) unawaited(_restoreAccount()); + if (mounted) unawaited(_restoreAccountSilently()); }); } - Future _restoreAccount() async { - await widget.controller.school.restoreAccount(); + Future _restoreAccountSilently() async { + await widget.controller.school.restoreAccount(notifyErrors: false); if (mounted) await _checkUpdates(silent: true); } diff --git a/lib/school/controller.dart b/lib/school/controller.dart index 72f770c..b08e5e2 100644 --- a/lib/school/controller.dart +++ b/lib/school/controller.dart @@ -192,7 +192,10 @@ class SchoolController extends ChangeNotifier { return true; }); - Future restoreAccount({VerifySchoolLogin? verify}) async { + Future restoreAccount({ + VerifySchoolLogin? verify, + bool notifyErrors = true, + }) async { if (_username == null) return false; return _run('恢复学校登录', () async { await _access.read( @@ -202,7 +205,7 @@ class SchoolController extends ChangeNotifier { service: verify == null ? null : SchoolNetwork.campus.accountPage, ); return true; - }); + }, notifyErrors: notifyErrors); } Future synchronize({String? term, VerifySchoolLogin? verify}) => _run( diff --git a/test/school_verification_test.dart b/test/school_verification_test.dart index 11ed528..912ca07 100644 --- a/test/school_verification_test.dart +++ b/test/school_verification_test.dart @@ -180,43 +180,40 @@ void main() { ); for (final kind in SchoolVerificationKind.values) { - test( - 'Startup defers $kind before loading a code or asking for input', - () async { - final server = await _LoginServer.start(kind); - addTearDown(() => server.server.close(force: true)); - final app = await _app(); - final schedule = app.timetable.schedule; - final deadline = app.agenda.entries.single; - final academics = app.academics.record; - final status = kind == SchoolVerificationKind.sms - ? AccountStatus.smsRequired - : AccountStatus.imageRequired; - final notice = expectLater( - AppDiagnostics.instance.messages, - emits('学校登录验证未完成,请在设置中完成验证。'), - ); - final success = await HttpOverrides.runWithHttpOverrides( - app.school.restoreAccount, - server.network, - ); - await notice; - expect(success, isFalse); - expect(app.school.accountStatus, status); - expect(app.school.busy, isFalse); - expect( - server.paths, - isNot(contains('/sso.bit.edu.cn/cas/captcha.png')), - ); - expect( - server.unexpected, - isEmpty, - ); // Also excludes phone lookup and SMS sending. - expect(app.timetable.schedule, same(schedule)); - expect(app.agenda.entries.single, same(deadline)); - expect(app.academics.record, same(academics)); - }, - ); + test('Startup defers $kind without interrupting the interface', () async { + final server = await _LoginServer.start(kind); + addTearDown(() => server.server.close(force: true)); + final app = await _app(); + final schedule = app.timetable.schedule; + final deadline = app.agenda.entries.single; + final academics = app.academics.record; + final status = kind == SchoolVerificationKind.sms + ? AccountStatus.smsRequired + : AccountStatus.imageRequired; + final notices = []; + final subscription = AppDiagnostics.instance.messages + .where((message) => message.startsWith('学校登录验证未完成')) + .listen(notices.add); + addTearDown(subscription.cancel); + + final success = await HttpOverrides.runWithHttpOverrides( + () => app.school.restoreAccount(notifyErrors: false), + server.network, + ); + await Future.delayed(Duration.zero); + expect(success, isFalse); + expect(notices, isEmpty); + expect(app.school.accountStatus, status); + expect(app.school.busy, isFalse); + expect(server.paths, isNot(contains('/sso.bit.edu.cn/cas/captcha.png'))); + expect( + server.unexpected, + isEmpty, + ); // Also excludes phone lookup and SMS sending. + expect(app.timetable.schedule, same(schedule)); + expect(app.agenda.entries.single, same(deadline)); + expect(app.academics.record, same(academics)); + }); } test('Manual verification reaches SSO despite a valid service session, and cancellation is retryable', () async { From 183a58bb49b1b111d416e0b996c12447e329cf70 Mon Sep 17 00:00:00 2001 From: so1ve <58381667+so1ve@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:42:32 +0800 Subject: [PATCH 06/10] test(ui): align audit checks with current interface --- lib/academics/ui/credit_targets_dialog.dart | 1 + lib/app/ui/logs_screen.dart | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/academics/ui/credit_targets_dialog.dart b/lib/academics/ui/credit_targets_dialog.dart index 6757152..5182360 100644 --- a/lib/academics/ui/credit_targets_dialog.dart +++ b/lib/academics/ui/credit_targets_dialog.dart @@ -93,6 +93,7 @@ class _CreditTargetsDialogState extends State<_CreditTargetsDialog> { @override Widget build(BuildContext context) { final l10n = context.l10n; + final theme = Theme.of(context); return PopScope( canPop: !_saving, child: AlertDialog( diff --git a/lib/app/ui/logs_screen.dart b/lib/app/ui/logs_screen.dart index 6529c72..ed8b88e 100644 --- a/lib/app/ui/logs_screen.dart +++ b/lib/app/ui/logs_screen.dart @@ -110,7 +110,7 @@ class _LogsScreenState extends State { const SizedBox(width: 8), TextButton( onPressed: _loading ? null : _load, - child: Text(l10n.retry), + child: Text(l10n.refresh), ), ], ), From 932356ec073ed2f729daa85c856921c8161f8fdf Mon Sep 17 00:00:00 2001 From: so1ve <58381667+so1ve@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:46:01 +0800 Subject: [PATCH 07/10] refactor(sync): clarify deadline source grouping --- lib/l10n/app_en.arb | 2 +- lib/l10n/app_localizations.dart | 2 +- lib/l10n/app_localizations_en.dart | 2 +- lib/l10n/app_localizations_zh.dart | 2 +- lib/l10n/app_zh.arb | 2 +- test/sync_button_test.dart | 1 + test/sync_test.dart | 4 ++-- 7 files changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 748a59d..68e0251 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -806,7 +806,7 @@ "syncAll": "Sync all", "customSync": "Custom sync", "syncSchoolData": "School data", - "syncDeadlineData": "Deadlines and coursework", + "syncDeadlineData": "DDL sources", "syncTooltip": "Sync", "viewSyncProgress": "View sync progress", "syncComplete": "Sync complete", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 949157e..8cf456c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1755,7 +1755,7 @@ abstract class AppLocalizations { /// No description provided for @syncDeadlineData. /// /// In en, this message translates to: - /// **'Deadlines and coursework'** + /// **'DDL sources'** String get syncDeadlineData; /// No description provided for @syncTooltip. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a0d4167..0b067a2 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1023,7 +1023,7 @@ class AppLocalizationsEn extends AppLocalizations { String get syncSchoolData => 'School data'; @override - String get syncDeadlineData => 'Deadlines and coursework'; + String get syncDeadlineData => 'DDL sources'; @override String get syncTooltip => 'Sync'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index d9231c4..6f08cb5 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -974,7 +974,7 @@ class AppLocalizationsZh extends AppLocalizations { String get syncSchoolData => '学校数据'; @override - String get syncDeadlineData => 'DDL 与课业'; + String get syncDeadlineData => 'DDL 来源'; @override String get syncTooltip => '同步'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index e882ccc..3c69840 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -272,7 +272,7 @@ "syncAll": "同步全部", "customSync": "自定义同步", "syncSchoolData": "学校数据", - "syncDeadlineData": "DDL 与课业", + "syncDeadlineData": "DDL 来源", "syncTooltip": "同步", "viewSyncProgress": "查看同步进度", "syncComplete": "同步完成", diff --git a/test/sync_button_test.dart b/test/sync_button_test.dart index 6303341..ea1f80a 100644 --- a/test/sync_button_test.dart +++ b/test/sync_button_test.dart @@ -55,6 +55,7 @@ void main() { expect(find.text('成绩'), findsNothing); expect(find.text('学分进度'), findsNothing); expect(find.text('课表'), findsOneWidget); + expect(find.text('DDL 来源'), findsOneWidget); expect(find.text('大物实验 DDL'), findsOneWidget); expect(tester.takeException(), isNull); }); diff --git a/test/sync_test.dart b/test/sync_test.dart index 50fbbdb..31d3886 100644 --- a/test/sync_test.dart +++ b/test/sync_test.dart @@ -431,10 +431,10 @@ void main() { await tester.pumpAndSettle(); expect(find.text('学校数据'), findsOneWidget); - expect(find.text('DDL 与课业'), findsOneWidget); + expect(find.text('DDL 来源'), findsOneWidget); expect( tester.getTopLeft(find.text('学校数据')).dy, - lessThan(tester.getTopLeft(find.text('DDL 与课业')).dy), + lessThan(tester.getTopLeft(find.text('DDL 来源')).dy), ); expect(find.widgetWithText(AppSelectionTile, '课表'), findsOneWidget); expect(find.widgetWithText(AppSelectionTile, '乐学 DDL'), findsOneWidget); From e0aac993f7708ebe76b4200ae82726284a0761c9 Mon Sep 17 00:00:00 2001 From: so1ve <58381667+so1ve@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:36:49 +0800 Subject: [PATCH 08/10] fix(schedule): clarify course conflict cards --- docs/ui-design.md | 2 + lib/schedule/ui/conflicting_courses.dart | 286 +++++++++++------------ test/schedule_conflicts_test.dart | 50 ++++ 3 files changed, 187 insertions(+), 151 deletions(-) diff --git a/docs/ui-design.md b/docs/ui-design.md index fa7773b..07b1969 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -96,6 +96,8 @@ Android 桌面小组件整卡都是打开应用的点击区域,包括标题、 点击课程打开课程 drawer,展示下一门课和换楼关系;点击下一门课在同一 drawer 内查看。点击日期打开当日日程 drawer,复用日程列表的完成与滑动操作,已完成项折叠。 课程不计入日程数量,也不跳转到日程页面。 +课程冲突块使用中性承托面,顶部显示冲突数量;每门课程以自身配色形成连续色带, +宽栏并排显示课程名与精确节次,窄栏分两行显示,不再叠加重复的悬浮详情。 日程列表以顶部的“未完成/已完成”分段选择切换内容,不同时使用横滑切页。 条目横拖立即跟手:短滑露出可点击的操作按钮,长滑达到阈值后振动并显示松手提示。 diff --git a/lib/schedule/ui/conflicting_courses.dart b/lib/schedule/ui/conflicting_courses.dart index 64b2a2f..dcabb41 100644 --- a/lib/schedule/ui/conflicting_courses.dart +++ b/lib/schedule/ui/conflicting_courses.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart'; import '../../app/controller.dart'; @@ -77,12 +75,6 @@ class ConflictingCourses extends StatelessWidget { Widget build(BuildContext context) { final l10n = context.l10n; final scheme = Theme.of(context).colorScheme; - final firstSection = courses - .map((course) => course.startSection!) - .reduce(math.min); - final lastSection = courses - .map((course) => course.endSection!) - .reduce(math.max); final summary = courses .map( (course) => @@ -95,132 +87,77 @@ class ConflictingCourses extends StatelessWidget { button: true, label: l10n.coursesOverlap(courses.length, summary), child: ExcludeSemantics( - child: Tooltip( - triggerMode: TooltipTriggerMode.manual, - message: summary, - child: Material( - color: Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: AppCorners.compact, - side: BorderSide( - color: isToday ? scheme.primary : scheme.outlineVariant, - width: isToday ? 1.5 : 1, - ), + child: Material( + color: scheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: AppCorners.compact, + side: BorderSide( + color: isToday ? scheme.primary : scheme.outlineVariant, + width: isToday ? 1.5 : 1, ), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => _selectCourse(context), - child: LayoutBuilder( - builder: (context, constraints) { - final showCount = constraints.maxHeight >= 32; - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 2, - vertical: 2, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (showCount) ...[ - Row( + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => _selectCourse(context), + child: LayoutBuilder( + builder: (context, constraints) { + final showHeader = constraints.maxHeight >= 30; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showHeader) + ColoredBox( + color: isToday + ? scheme.primaryContainer + : scheme.surfaceContainer, + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 3, 4, 3), + child: Row( children: [ Icon( Icons.warning_amber_rounded, size: 11, color: scheme.primary, ), - const SizedBox(width: 1), - Flexible( + const SizedBox(width: 2), + Expanded( child: Text( l10n.courseOverlapCount(courses.length), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 9, - height: 1, + height: 1.1, fontWeight: FontWeight.w700, - color: scheme.primary, + color: isToday + ? scheme.onPrimaryContainer + : scheme.onSurfaceVariant, ), ), ), ], ), - const SizedBox(height: 2), - ], - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - const rowHeight = 20.0; - final positionsAtStart = - constraints.maxHeight >= - courses.length * (rowHeight + 1); - if (!positionsAtStart) { - return Column( - children: [ - for (final course in courses) - Expanded( - child: _ConflictCourseRow( - course: course, - section: localizedCourseSection( - l10n, - course, - ), - ), - ), - ], - ); - } - - final span = lastSection - firstSection + 1; - final sorted = [...courses] - ..sort((first, second) { - final section = first.startSection!.compareTo( - second.startSection!, - ); - return section != 0 - ? section - : first.name.compareTo(second.name); - }); - final rows = []; - var nextTop = 0.0; - for (final course in sorted) { - final offset = - course.startSection! - firstSection; - final proposed = - offset / span * constraints.maxHeight; - final top = math - .max(proposed, nextTop) - .clamp( - 0.0, - constraints.maxHeight - rowHeight, - ) - .toDouble(); - nextTop = top + rowHeight + 1; - rows.add( - Positioned( - left: 0, - right: 0, - top: top, - height: rowHeight, - child: _ConflictCourseRow( - course: course, - section: localizedCourseSection( - l10n, - course, - ), - ), - ), - ); - } - return Stack(children: rows); - }, - ), ), - ], + ), + Expanded( + child: Column( + children: [ + for (final (index, course) in courses.indexed) ...[ + if (index > 0) const SizedBox(height: 1), + Expanded( + child: _ConflictCourseRow( + key: ValueKey('conflict-course-${course.id}'), + course: course, + section: localizedCourseSection(l10n, course), + ), + ), + ], + ], + ), ), - ); - }, - ), + ], + ); + }, ), ), ), @@ -230,7 +167,11 @@ class ConflictingCourses extends StatelessWidget { } class _ConflictCourseRow extends StatelessWidget { - const _ConflictCourseRow({required this.course, required this.section}); + const _ConflictCourseRow({ + super.key, + required this.course, + required this.section, + }); final Course course; final String section; @@ -238,44 +179,87 @@ class _ConflictCourseRow extends StatelessWidget { @override Widget build(BuildContext context) { final colors = courseColors(context, course); - return DecoratedBox( - decoration: BoxDecoration( - color: colors.fill, - borderRadius: BorderRadius.circular(3), - border: Border(left: BorderSide(color: colors.accent, width: 2)), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1), - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - course.name, - maxLines: 1, - style: TextStyle( - fontSize: 9, - height: 1.05, - fontWeight: FontWeight.w600, - color: colors.text, - ), - ), - const SizedBox(height: 1), - Text( - section, - maxLines: 1, - style: TextStyle( - fontSize: 8, - height: 1, - color: colors.secondaryText, - ), - ), - ], + return ColoredBox( + color: colors.fill, + child: Stack( + fit: StackFit.expand, + children: [ + Positioned( + left: 0, + top: 0, + bottom: 0, + width: 3, + child: ColoredBox(color: colors.accent), ), - ), + Padding( + padding: const EdgeInsets.fromLTRB(6, 2, 3, 2), + child: LayoutBuilder( + builder: (context, constraints) { + final inline = constraints.maxWidth >= 88; + final showSection = constraints.maxHeight >= (inline ? 16 : 25); + final name = Text( + course.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10, + height: 1.08, + fontWeight: FontWeight.w600, + color: colors.text, + ), + ); + final sectionText = Text( + section, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 8.5, + height: 1.05, + fontWeight: FontWeight.w500, + color: colors.secondaryText, + ), + ); + final content = inline + ? Row( + children: [ + Expanded(child: name), + if (showSection) ...[ + const SizedBox(width: 3), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 72), + child: DecoratedBox( + decoration: BoxDecoration( + color: colors.text.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(2), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 3, + vertical: 1, + ), + child: sectionText, + ), + ), + ), + ], + ], + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + name, + if (showSection) ...[ + const SizedBox(height: 1), + sectionText, + ], + ], + ); + return Align(alignment: Alignment.centerLeft, child: content); + }, + ), + ), + ], ), ); } diff --git a/test/schedule_conflicts_test.dart b/test/schedule_conflicts_test.dart index 8ae64d9..3465259 100644 --- a/test/schedule_conflicts_test.dart +++ b/test/schedule_conflicts_test.dart @@ -116,4 +116,54 @@ void main() { expect(tester.takeException(), isNull); }, ); + + testWidgets( + 'A wide conflict card fills its height with distinct course bands', + (tester) async { + final courses = [ + course('a', 'erwer', start: 6, end: 7), + course('b', '大学物理 A II', start: 6, end: 7), + ]; + final app = TestAppController( + schedule: SemesterSchedule( + term: '2026-2027-1', + firstMonday: DateTime.utc(2026, 10, 26), + courses: courses, + ), + ); + await app.initialize(); + + await tester.pumpWidget( + MaterialApp( + theme: bitreeTheme(Brightness.light), + home: Scaffold( + body: Center( + child: SizedBox( + width: 240, + height: 180, + child: ConflictingCourses( + courses: courses, + controller: app, + isToday: false, + date: date, + ), + ), + ), + ), + ), + ); + + final first = tester.getRect( + find.byKey(const ValueKey('conflict-course-a')), + ); + final second = tester.getRect( + find.byKey(const ValueKey('conflict-course-b')), + ); + expect(first.height, closeTo(second.height, 1)); + expect(first.height, greaterThan(40)); + expect(first.bottom, lessThan(second.top)); + expect(find.byType(Tooltip), findsNothing); + expect(tester.takeException(), isNull); + }, + ); } From 286827ca4d92e86fa15af2da37844a2d1b945c7e Mon Sep 17 00:00:00 2001 From: so1ve <58381667+so1ve@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:44:54 +0800 Subject: [PATCH 09/10] fix(schedule): show course locations in conflicts --- docs/ui-design.md | 2 +- lib/schedule/ui/conflicting_courses.dart | 163 ++++++++++++----------- test/schedule_conflicts_test.dart | 8 +- 3 files changed, 93 insertions(+), 80 deletions(-) diff --git a/docs/ui-design.md b/docs/ui-design.md index 07b1969..68d141c 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -97,7 +97,7 @@ Android 桌面小组件整卡都是打开应用的点击区域,包括标题、 内查看。点击日期打开当日日程 drawer,复用日程列表的完成与滑动操作,已完成项折叠。 课程不计入日程数量,也不跳转到日程页面。 课程冲突块使用中性承托面,顶部显示冲突数量;每门课程以自身配色形成连续色带, -宽栏并排显示课程名与精确节次,窄栏分两行显示,不再叠加重复的悬浮详情。 +直接显示课程名、建筑或教室与精确节次,不再叠加重复的悬浮详情或强调色侧边条。 日程列表以顶部的“未完成/已完成”分段选择切换内容,不同时使用横滑切页。 条目横拖立即跟手:短滑露出可点击的操作按钮,长滑达到阈值后振动并显示松手提示。 diff --git a/lib/schedule/ui/conflicting_courses.dart b/lib/schedule/ui/conflicting_courses.dart index dcabb41..4f47e8c 100644 --- a/lib/schedule/ui/conflicting_courses.dart +++ b/lib/schedule/ui/conflicting_courses.dart @@ -178,88 +178,97 @@ class _ConflictCourseRow extends StatelessWidget { @override Widget build(BuildContext context) { + final location = course.room.trim().isEmpty + ? context.l10n.locationPending + : course.room; final colors = courseColors(context, course); return ColoredBox( color: colors.fill, - child: Stack( - fit: StackFit.expand, - children: [ - Positioned( - left: 0, - top: 0, - bottom: 0, - width: 3, - child: ColoredBox(color: colors.accent), - ), - Padding( - padding: const EdgeInsets.fromLTRB(6, 2, 3, 2), - child: LayoutBuilder( - builder: (context, constraints) { - final inline = constraints.maxWidth >= 88; - final showSection = constraints.maxHeight >= (inline ? 16 : 25); - final name = Text( - course.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 10, - height: 1.08, - fontWeight: FontWeight.w600, - color: colors.text, - ), - ); - final sectionText = Text( - section, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 8.5, - height: 1.05, - fontWeight: FontWeight.w500, - color: colors.secondaryText, - ), - ); - final content = inline - ? Row( - children: [ - Expanded(child: name), - if (showSection) ...[ - const SizedBox(width: 3), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 72), - child: DecoratedBox( - decoration: BoxDecoration( - color: colors.text.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(2), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 3, - vertical: 1, - ), - child: sectionText, - ), + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 2, 3, 2), + child: LayoutBuilder( + builder: (context, constraints) { + final inline = constraints.maxWidth >= 88; + final showDetails = constraints.maxHeight >= (inline ? 25 : 36); + final name = Text( + course.name, + maxLines: inline ? 1 : 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10, + height: 1.08, + fontWeight: FontWeight.w600, + color: colors.text, + ), + ); + final locationText = Text( + location, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 8.5, + height: 1.05, + color: colors.secondaryText, + ), + ); + final sectionText = Text( + section, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 8.5, + height: 1.05, + fontWeight: FontWeight.w500, + color: colors.secondaryText, + ), + ); + final details = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + name, + if (showDetails) ...[const SizedBox(height: 1), locationText], + ], + ); + final content = inline + ? Row( + children: [ + Expanded(child: details), + if (showDetails) ...[ + const SizedBox(width: 3), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 72), + child: DecoratedBox( + decoration: BoxDecoration( + color: colors.text.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(2), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 3, + vertical: 1, ), + child: sectionText, ), - ], - ], - ) - : Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - name, - if (showSection) ...[ - const SizedBox(height: 1), - sectionText, - ], - ], - ); - return Align(alignment: Alignment.centerLeft, child: content); - }, - ), - ), - ], + ), + ), + ], + ], + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + details, + if (showDetails) ...[ + const SizedBox(height: 1), + sectionText, + ], + ], + ); + return Align(alignment: Alignment.centerLeft, child: content); + }, + ), ), ); } diff --git a/test/schedule_conflicts_test.dart b/test/schedule_conflicts_test.dart index 3465259..5f51682 100644 --- a/test/schedule_conflicts_test.dart +++ b/test/schedule_conflicts_test.dart @@ -16,9 +16,11 @@ void main() { String name, { required int start, required int end, + String room = '', }) => Course( id: id, name: name, + room: room, weekday: DateTime.friday, startSection: start, endSection: end, @@ -121,8 +123,8 @@ void main() { 'A wide conflict card fills its height with distinct course bands', (tester) async { final courses = [ - course('a', 'erwer', start: 6, end: 7), - course('b', '大学物理 A II', start: 6, end: 7), + course('a', 'erwer', room: '文萃楼 F704', start: 6, end: 7), + course('b', '大学物理 A II', room: '理教楼 105', start: 6, end: 7), ]; final app = TestAppController( schedule: SemesterSchedule( @@ -162,6 +164,8 @@ void main() { expect(first.height, closeTo(second.height, 1)); expect(first.height, greaterThan(40)); expect(first.bottom, lessThan(second.top)); + expect(find.text('文萃楼 F704'), findsOneWidget); + expect(find.text('理教楼 105'), findsOneWidget); expect(find.byType(Tooltip), findsNothing); expect(tester.takeException(), isNull); }, From 925e1e5f2eb27efa1b5140f4515b3bb5fe2b2370 Mon Sep 17 00:00:00 2001 From: so1ve <58381667+so1ve@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:10:44 +0800 Subject: [PATCH 10/10] fix(schedule): hide missing conflict locations --- docs/ui-design.md | 3 ++- lib/schedule/ui/conflicting_courses.dart | 31 +++++++++++++----------- test/schedule_conflicts_test.dart | 1 + 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/docs/ui-design.md b/docs/ui-design.md index 68d141c..cc70028 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -97,7 +97,8 @@ Android 桌面小组件整卡都是打开应用的点击区域,包括标题、 内查看。点击日期打开当日日程 drawer,复用日程列表的完成与滑动操作,已完成项折叠。 课程不计入日程数量,也不跳转到日程页面。 课程冲突块使用中性承托面,顶部显示冲突数量;每门课程以自身配色形成连续色带, -直接显示课程名、建筑或教室与精确节次,不再叠加重复的悬浮详情或强调色侧边条。 +直接显示课程名、已有建筑或教室与精确节次;地点为空时不显示占位文案,也不再叠加 +重复的悬浮详情或强调色侧边条。 日程列表以顶部的“未完成/已完成”分段选择切换内容,不同时使用横滑切页。 条目横拖立即跟手:短滑露出可点击的操作按钮,长滑达到阈值后振动并显示松手提示。 diff --git a/lib/schedule/ui/conflicting_courses.dart b/lib/schedule/ui/conflicting_courses.dart index 4f47e8c..2f61cd3 100644 --- a/lib/schedule/ui/conflicting_courses.dart +++ b/lib/schedule/ui/conflicting_courses.dart @@ -178,9 +178,7 @@ class _ConflictCourseRow extends StatelessWidget { @override Widget build(BuildContext context) { - final location = course.room.trim().isEmpty - ? context.l10n.locationPending - : course.room; + final location = course.room.trim(); final colors = courseColors(context, course); return ColoredBox( color: colors.fill, @@ -201,16 +199,18 @@ class _ConflictCourseRow extends StatelessWidget { color: colors.text, ), ); - final locationText = Text( - location, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 8.5, - height: 1.05, - color: colors.secondaryText, - ), - ); + final locationText = location.isEmpty + ? null + : Text( + location, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 8.5, + height: 1.05, + color: colors.secondaryText, + ), + ); final sectionText = Text( section, maxLines: 1, @@ -227,7 +227,10 @@ class _ConflictCourseRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ name, - if (showDetails) ...[const SizedBox(height: 1), locationText], + if (showDetails && locationText != null) ...[ + const SizedBox(height: 1), + locationText, + ], ], ); final content = inline diff --git a/test/schedule_conflicts_test.dart b/test/schedule_conflicts_test.dart index 5f51682..56dbae4 100644 --- a/test/schedule_conflicts_test.dart +++ b/test/schedule_conflicts_test.dart @@ -102,6 +102,7 @@ void main() { expect(find.text(item.name), findsOneWidget); expect(find.text(item.sectionLabel), findsOneWidget); } + expect(find.text('教室待定'), findsNothing); expect(tester.takeException(), isNull); await tester.tap(find.byType(ConflictingCourses));