From c5a583c552bfa52fc9eec571e0dfbf8d8aac7e82 Mon Sep 17 00:00:00 2001 From: Kiro Date: Thu, 15 Oct 2026 04:33:38 +1100 Subject: [PATCH 1/2] =?UTF-8?q?fix(notifications):=20=E8=87=AA=E5=AE=A1=20?= =?UTF-8?q?A=20=E7=BB=84=E2=80=94=E2=80=94=E3=80=8C=E6=9F=A5=E7=9C=8B?= =?UTF-8?q?=E3=80=8D=E8=B5=B0=E6=8C=82=E8=B5=B7=E5=AE=9A=E4=BD=8D=E3=80=81?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86=E4=B8=8A=20/=20=E6=B2=A1=E9=93=83?= =?UTF-8?q?=E9=93=9B=E6=97=B6=E4=B8=8D=E5=BC=80=E5=8E=86=E5=8F=B2=E3=80=81?= =?UTF-8?q?=E6=8A=98=E5=8F=A0=E5=8E=BB=E9=87=8D=E9=9C=B2=E5=87=BA=E3=80=81?= =?UTF-8?q?=E6=B8=85=E7=A9=BA=E8=BF=9E=E5=B8=A6=E6=8A=98=E5=8F=A0=E5=8D=A1?= =?UTF-8?q?=E3=80=81=E8=AF=BB=E5=B1=8F=E4=B8=8E=E9=93=83=E9=93=9B=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=E3=80=81=E7=B3=BB=E7=BB=9F=E6=8F=90=E7=A4=BA=E9=9F=B3?= =?UTF-8?q?=E5=BD=92=E7=B3=BB=E7=BB=9F=E9=9F=B3=E9=87=8F=E3=80=81=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E6=97=B6=E9=97=B4=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 通知卡「查看」改走 canvasFocusRequestProvider:原先切画布同一刻直接写选中,目标画布没挂载时被 autoDispose 回收(#289 漏掉的入口);那条测试人为订阅保活、同步读值,一直是绿的,改成断言请求。 - ToastCenterState.historyHostReady:铃铛按自己所在路由是否在最上层上报。上面压着对话框时历史面板会 画在遮罩底下、还抢走对话框焦点;启动失败页没有铃铛,开了历史就关不掉——这两种时候「还有 N 条」 不可点、开着的历史立即关。 - 去重替换到一张被折叠的卡:挪到最新位置露出来(正在显示的仍原地换)。 - 清空历史连带折叠的卡;折叠着的进度卡与它那一行历史照留。 - 失败卡原地换成新的失败也重新播报;铃铛语义标签带上未读 / 未读失败。 - Windows 系统提示音加 SND_SYSTEM,归到系统声音的音量会话。 - 历史面板开着时每 30 秒刷新相对时间。 - docs:notification_sound 目录、ffi 依赖、job_notification_listener 的描述改准。 Co-Authored-By: Claude Opus 5.5 --- docs/CLAUDE.md | 6 +- .../notifications/providers/toast_center.dart | 66 +++++++++++++-- .../widgets/job_notification_listener.dart | 12 +-- .../widgets/notification_bell.dart | 59 +++++++++++++- .../notifications/widgets/toast_layer.dart | 5 +- lib/l10n/app_en.arb | 4 + lib/l10n/app_zh.arb | 2 + lib/l10n/generated/app_localizations.dart | 12 +++ lib/l10n/generated/app_localizations_en.dart | 6 ++ lib/l10n/generated/app_localizations_zh.dart | 6 ++ .../system_alert_sound.dart | 12 ++- lib/theme/components/ink_toast_card.dart | 8 +- .../providers/toast_center_test.dart | 54 +++++++++++++ .../job_notification_listener_test.dart | 12 ++- .../widgets/notification_bell_test.dart | 81 ++++++++++++++++++- .../widgets/toast_layer_test.dart | 20 +++++ .../theme/components/ink_toast_card_test.dart | 44 ++++++++++ 17 files changed, 382 insertions(+), 27 deletions(-) diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index a6131979..16126e21 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -13,6 +13,7 @@ - **Video export**: external ffmpeg binary (runtime probe via `FfmpegLocator`; not bundled) - **Window chrome**: window_manager (frameless shell); screen_retriever (multi-monitor enumeration for window-state restore clamp) - **File import**: file_selector +- **FFI**: ffi(Windows「系统提示音」直接调 winmm 的 PlaySoundW,见 services/notification_sound/system_alert_sound.dart) - **Secure storage**: flutter_secure_storage (macOS Keychain / Windows Credential Manager); Debug+macOS falls back to a plaintext dev file (see Provider API Keys) - **Platforms**: macOS + Windows @@ -261,7 +262,7 @@ lib/ │ ├── notifications/ # 右下角通知卡 + 铃铛历史 + 生成完成 / 失败通知(ToastService 的实现;卡片层挂在 MaterialApp.builder,画在所有路由之上) │ │ ├── models/ # toast_entry.dart(一张卡的不可变快照,也是历史里的一行)+ notification_prefs.dart(提示音 / 停留时长偏好字符串 → 枚举) │ │ ├── providers/ # toast_center.dart(ToastCenter:按类型计时、悬停 / 窗口失焦停表、dedupeKey 原地替换、最多 3 张在场其余折叠、本次会话历史 100 条 + 未读、chime 时按设置响一声)+ toast_service_provider.dart(以 core 契约类型暴露 ToastCenter) -│ │ └── widgets/ # toast_layer.dart(builder 层:右下角卡片栈 + 进退场动画 +「还有 N 条」)/ job_notification_listener.dart(jobsRegistry 新终态 → 完成 / 失败卡,不为通知查库)/ notification_bell.dart(外壳 chrome 的铃铛 + OverlayPortal 历史下拉) +│ │ └── widgets/ # toast_layer.dart(builder 层:右下角卡片栈 + 进退场动画 +「还有 N 条」)/ job_notification_listener.dart(jobsRegistry 新终态 → 完成 / 失败卡;只有视频成片缺首帧缩略时才查一次库取 thumbnail_url;「查看」走挂起定位请求 canvasFocusRequestProvider)/ notification_bell.dart(外壳 chrome 的铃铛 + OverlayPortal 历史下拉;铃铛按自己所在路由是否在最上层上报 historyHostReady——上面压着对话框 / 没有铃铛时卡片栈的「还有 N 条」不可点) │ ├── settings/ # Settings overlay dialog (Screens 稿第 3 屏: 1120×740 居中, 40 标题栏 | 左导航 200 + 页 | 44 底部条; 自持焦 + Esc 分层; 开关不写路由) │ │ ├── settings_screen.dart # SettingsScreen (壳走 theme 层的 InkOverlayDialog + 左导航 + 各页正文 + 底部条 完成/导出诊断包); pages = 常规 / API 密钥 / 快捷键 / 性能 / 节点布局 / 网络 / 存储 / 关于(后三页里快捷键 / 性能 / 网络是 P7 补的只读页) │ │ ├── providers/ @@ -342,6 +343,9 @@ lib/ ├── window_state_service.dart # DefaultWindowStateService + clampBoundsToVisible pure fn (window size/pos/maximized memory; multi-monitor clamp; PL-6) ├── window_manager_adapters.dart # WindowManagerWindowController + ScreenRetrieverDisplayQuery (real plugin seams behind WindowController/DisplayQuery) ├── crash_reporter.dart # FileCrashReporter (uncaught-error crash file + keep-3 rotation, no context/extra) + ├── notification_sound/ # 提示音出声:notification_sound_player.dart(NotificationSoundService 实现:InkFrame 默认 / 系统 / 自定义 / 关闭四档,1200ms 节流)+ audio_clip_player.dart(media_kit 播 asset 或自定义文件,最长 4 秒)+ system_alert_sound.dart(Windows PlaySoundW 别名 Notification.Default / macOS NSBeep) + ├── file_notification_sound_store.dart # 自定义提示音文件:config/sounds 下复制一份(扩展名白名单、≤2MB、先写 .partial 再改名),按文件名解析 + ├── silent_notification_sound_service.dart # 默认的静音实现(main.dart 覆盖成真播放器;测试默认不出声) ├── error_hooks.dart # installErrorHooks + reportUncaught (FlutterError/PlatformDispatcher/zone → logger + CrashReporter) ├── lifecycle_timer.dart # LifecycleTimer (startup stage timing → app.lifecycle {stage, ms}; see docs/perf-baseline.md) ├── image_cache_config.dart # configureImageCache (LB-23: ImageCache 上限 256MB; main bootstrap 调用一次) diff --git a/lib/features/notifications/providers/toast_center.dart b/lib/features/notifications/providers/toast_center.dart index 2e4f1232..2453a98f 100644 --- a/lib/features/notifications/providers/toast_center.dart +++ b/lib/features/notifications/providers/toast_center.dart @@ -62,6 +62,7 @@ class ToastCenterState { this.history = const [], this.unreadIds = const {}, this.historyOpen = false, + this.historyHostReady = false, }); /// 右下角的卡,旧 → 新;含正在淡出的。 @@ -76,6 +77,12 @@ class ToastCenterState { /// 铃铛下拉是否开着。 final bool historyOpen; + /// 铃铛此刻能不能承接历史面板:铃铛挂着、且它所在的路由在最上层(上面没压对话框)。 + /// 由铃铛上报(NotificationBell)。不能时卡片栈上「还有 N 条 · 查看全部」不可点——面板挂在根 Overlay + /// 里铃铛那层路由的后面,上面压着对话框时会画在遮罩底下、还抢走对话框的焦点;启动失败页没有铃铛, + /// 开了历史就再没有东西能关掉它(卡片栈一直藏着、计时一直停着)。 + final bool historyHostReady; + int get unreadCount => unreadIds.length; /// 未读里有失败 ⇒ 铃铛上的点用 danger 而不是 accent。 @@ -120,12 +127,14 @@ class ToastCenterState { List? history, Set? unreadIds, bool? historyOpen, + bool? historyHostReady, }) => ToastCenterState( live: live ?? this.live, history: history ?? this.history, unreadIds: unreadIds ?? this.unreadIds, historyOpen: historyOpen ?? this.historyOpen, + historyHostReady: historyHostReady ?? this.historyHostReady, ); } @@ -215,10 +224,19 @@ class ToastCenter extends Notifier implements ToastService { dedupeKey: dedupeKey, dwell: dwell, ); + // 正在显示的那张:原地换内容,不跳位。被折叠的那张:挪到最新的位置露出来——否则提示音响了、铃铛点 + // 也红了,卡片栈上却看不到任何变化(一次全新的失败反倒看得见)。 + final bool collapsed = !s.visibleIds.contains(old.id); live = List.unmodifiable( - [ - for (final ToastEntry e in s.live) e.id == old.id ? entry : e, - ], + collapsed + ? [ + for (final ToastEntry e in s.live) + if (e.id != old.id) e, + entry, + ] + : [ + for (final ToastEntry e in s.live) e.id == old.id ? entry : e, + ], ); } else { entry = ToastEntry( @@ -277,14 +295,50 @@ class ToastCenter extends Notifier implements ToastService { _syncTicker(); } - /// 清空历史(右下角在场的卡不受影响)。 + /// 铃铛上报能不能承接历史面板(见 [ToastCenterState.historyHostReady])。变成不能时,开着的历史立即关掉。 + void setHistoryHostReady(bool ready) { + final ToastCenterState s = state; + if (s.historyHostReady == ready) return; + state = s.copyWith( + historyHostReady: ready, + historyOpen: ready ? s.historyOpen : false, + ); + _syncTicker(); + } + + /// 清空历史,连同折叠在「还有 N 条」里的卡:它们就是历史里的那几条,清空后面板空了,栈上却还留着 + /// 「还有 N 条 · 查看全部」,点开又是空的——两头都看不到。正在显示的卡照留(各自还在计时)。 + /// 折叠着的进度卡也照留(任务还在跑,完成时要原地变成成功卡),连同它在历史里的那一行—— + /// 栈上「还有 N 条」数的正是它们,点开面板得看得到。 void clearHistory() { final ToastCenterState s = state; - if (s.history.isEmpty && s.unreadIds.isEmpty) return; + final Set visible = s.visibleIds; + final Set keptCollapsed = { + for (final ToastEntry e in s.live) + if (!e.leaving && !visible.contains(e.id) && e.kind == ToastKind.progress) e.id, + }; + final List live = [ + for (final ToastEntry e in s.live) + if (e.leaving || visible.contains(e.id) || keptCollapsed.contains(e.id)) e, + ]; + final List history = [ + for (final ToastEntry h in s.history) + if (keptCollapsed.contains(h.id)) h, + ]; + if (history.length == s.history.length && + s.unreadIds.isEmpty && + live.length == s.live.length) { + return; + } + for (final ToastEntry e in s.live) { + if (!live.contains(e)) _remaining.remove(e.id); + } state = s.copyWith( - history: const [], + live: List.unmodifiable(live), + history: List.unmodifiable(history), unreadIds: const {}, ); + _syncTicker(); } void markAllRead() { diff --git a/lib/features/notifications/widgets/job_notification_listener.dart b/lib/features/notifications/widgets/job_notification_listener.dart index 832d7316..a8d29da1 100644 --- a/lib/features/notifications/widgets/job_notification_listener.dart +++ b/lib/features/notifications/widgets/job_notification_listener.dart @@ -32,7 +32,7 @@ import '../../../l10n/generated/app_localizations.dart'; import '../../../l10n/l10n_x.dart'; import '../../canvas/models/canvas_node.dart'; import '../../canvas/providers/canvas_nodes_controller.dart'; -import '../../canvas/providers/canvas_selection_controller.dart'; +import '../../canvas/providers/canvas_focus_request.dart'; import '../../generation/models/job_state.dart'; import '../../generation/providers/jobs_registry.dart'; import '../../shell/models/shell_state.dart'; @@ -182,8 +182,10 @@ class _JobNotificationListenerState } } - /// 「查看」:打开产物所在画布并选中 result 节点(与画廊「在画布中定位」同一做法, - /// gallery_actions.dart)。拿不到项目名就不给按钮。 + /// 「查看」:打开产物所在画布,挂起定位请求——画布挂上来后单选 result 节点并移到视口中心(与画廊 + /// 「在画布中定位」同一条路,canvas_focus_request.dart)。不能在切画布的同一刻直接写选中:目标画布 + /// 这时往往还没挂载(人在别的画布 / 标签上),它的选中 provider 没有订阅者,写进去帧末就被回收。 + /// 拿不到项目名就不给按钮。 ToastAction? _viewAction(JobSucceeded job) { final String? nodeId = job.resultNodeId; final ProjectRef? project = _projectRef(job.projectId); @@ -195,8 +197,8 @@ class _JobNotificationListenerState .read(shellControllerProvider.notifier) .openCanvas(canvasId, withProject: project); ref - .read(canvasSelectionControllerProvider(canvasId).notifier) - .select(nodeId); + .read(canvasFocusRequestProvider.notifier) + .request(canvasId: canvasId, nodeId: nodeId); }); } diff --git a/lib/features/notifications/widgets/notification_bell.dart b/lib/features/notifications/widgets/notification_bell.dart index c1d6dd2e..ebf1a0c9 100644 --- a/lib/features/notifications/widgets/notification_bell.dart +++ b/lib/features/notifications/widgets/notification_bell.dart @@ -13,6 +13,8 @@ // (面板开着时持焦)、再点一次铃铛 // // 交付进行中(deliveryBusyProvider)不锁铃铛:看历史不改任何东西,与 ⚙ 的「不许开设置」不是一回事。 +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -53,6 +55,12 @@ class _NotificationBellState extends ConsumerState { /// 面板先被「外面」关掉,铃铛的点击又把它打开。 final Object _tapGroup = Object(); + /// 上次上报给 ToastCenter 的「能不能承接历史面板」(null = 还没报过)。 + bool? _hostReported; + + /// 卸载时要报「不能承接」:dispose 里 ref 已不可用,在 initState 取好。 + late final ToastCenter _center = ref.read(toastCenterProvider.notifier); + @override void initState() { super.initState(); @@ -79,10 +87,24 @@ class _NotificationBellState extends ConsumerState { @override void dispose() { + // 树正在收尾,不在这一刻改 provider:帧后再报。 + final ToastCenter center = _center; + WidgetsBinding.instance.addPostFrameCallback((_) => center.setHistoryHostReady(false)); _panelFocus.dispose(); super.dispose(); } + /// 能不能承接历史面板:铃铛所在的路由在最上层。面板挂在根 Overlay 里本路由的后面——上面一旦压着 + /// 对话框,面板就画在遮罩底下、点不到,还会把焦点从对话框抢走。ModalRoute.of 建立依赖:路由压上 / + /// 弹走对话框时本组件重建,这里随之重报。 + void _reportHost(bool canHost) { + if (canHost == _hostReported) return; + _hostReported = canHost; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _center.setHistoryHostReady(canHost); + }); + } + void _toggle() { final ToastCenter center = ref.read(toastCenterProvider.notifier); if (ref.read(toastCenterProvider).historyOpen) { @@ -94,6 +116,7 @@ class _NotificationBellState extends ConsumerState { @override Widget build(BuildContext context) { + _reportHost(ModalRoute.of(context)?.isCurrent ?? true); final InkColors c = context.inkColors; final bool open = ref.watch( toastCenterProvider.select((ToastCenterState s) => s.historyOpen), @@ -119,7 +142,12 @@ class _NotificationBellState extends ConsumerState { child: InkActivatable( key: NotificationBell.buttonKey, onTap: _toggle, - semanticLabel: context.l10n.notificationsOpen, + // 未读 / 有未读失败不只靠点的颜色(读屏拿不到颜色),一并写进语义标签。 + semanticLabel: !unread + ? context.l10n.notificationsOpen + : (unreadError + ? context.l10n.notificationsOpenUnreadFailure + : context.l10n.notificationsOpenUnread), focusRingRadius: BorderRadius.circular(InkRadius.s3), builder: (BuildContext context, bool hovered, bool focused) { final bool lit = hovered || open; @@ -204,11 +232,36 @@ class _HistoryOverlay extends ConsumerWidget { } } -class _HistoryPanel extends ConsumerWidget { +class _HistoryPanel extends ConsumerStatefulWidget { const _HistoryPanel(); + /// 面板开着时多久刷新一次「N 分钟前」——按分钟显示,半分钟一次足够跟上。 + static const Duration refreshEvery = Duration(seconds: 30); + @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState<_HistoryPanel> createState() => _HistoryPanelState(); +} + +class _HistoryPanelState extends ConsumerState<_HistoryPanel> { + // 面板开着、又没有新通知时,没有任何东西触发重建,相对时间会停在打开那一刻。 + late final Timer _tick = Timer.periodic(_HistoryPanel.refreshEvery, (_) { + if (mounted) setState(() {}); + }); + + @override + void initState() { + super.initState(); + _tick; + } + + @override + void dispose() { + _tick.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { final InkColors c = context.inkColors; final InkTypography t = context.inkTypography; final AppLocalizations l = context.l10n; diff --git a/lib/features/notifications/widgets/toast_layer.dart b/lib/features/notifications/widgets/toast_layer.dart index f544f4b2..0d14ebe1 100644 --- a/lib/features/notifications/widgets/toast_layer.dart +++ b/lib/features/notifications/widgets/toast_layer.dart @@ -121,7 +121,8 @@ class _ToastStack extends ConsumerWidget { alignment: Alignment.centerRight, child: _MorePill( label: context.l10n.toastMore(more), - onTap: center.openHistory, + // 铃铛承接不了历史面板时(上面压着对话框 / 启动失败页没有铃铛)不可点,见 historyHostReady。 + onTap: s.historyHostReady ? center.openHistory : null, ), ), for (final ToastEntry e in shown) @@ -282,7 +283,7 @@ class _MorePill extends StatelessWidget { const _MorePill({required this.label, required this.onTap}); final String label; - final VoidCallback onTap; + final VoidCallback? onTap; @override Widget build(BuildContext context) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 00840b53..d9467307 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1402,6 +1402,10 @@ "toastMore": "{count} more · View all", "@toastMore": { "placeholders": { "count": { "type": "int" } } }, "notificationsOpen": "Notifications", + "notificationsOpenUnread": "Notifications, unread", + "@notificationsOpenUnread": {"description": "Screen-reader label of the bell when there are unread notifications (the unread dot is color only)."}, + "notificationsOpenUnreadFailure": "Notifications, unread failures", + "@notificationsOpenUnreadFailure": {"description": "Screen-reader label of the bell when unread notifications include a failure (the dot turns red)."}, "notificationsTitle": "Notifications", "notificationsEmpty": "No notifications yet", "notificationsClear": "Clear", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 9741b9de..c8df673a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1109,6 +1109,8 @@ "toastMore": "还有 {count} 条 · 查看全部", "@toastMore": { "placeholders": { "count": { "type": "int" } } }, "notificationsOpen": "通知", + "notificationsOpenUnread": "通知,有未读", + "notificationsOpenUnreadFailure": "通知,有未读的失败", "notificationsTitle": "通知", "notificationsEmpty": "还没有通知", "notificationsClear": "清空", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index c5e42416..ae6fd4f8 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -5306,6 +5306,18 @@ abstract class AppLocalizations { /// **'Notifications'** String get notificationsOpen; + /// Screen-reader label of the bell when there are unread notifications (the unread dot is color only). + /// + /// In en, this message translates to: + /// **'Notifications, unread'** + String get notificationsOpenUnread; + + /// Screen-reader label of the bell when unread notifications include a failure (the dot turns red). + /// + /// In en, this message translates to: + /// **'Notifications, unread failures'** + String get notificationsOpenUnreadFailure; + /// No description provided for @notificationsTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 06ba5c64..d45c8061 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -3011,6 +3011,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notificationsOpen => 'Notifications'; + @override + String get notificationsOpenUnread => 'Notifications, unread'; + + @override + String get notificationsOpenUnreadFailure => 'Notifications, unread failures'; + @override String get notificationsTitle => 'Notifications'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index ca609d14..29cb1d0b 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -2882,6 +2882,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get notificationsOpen => '通知'; + @override + String get notificationsOpenUnread => '通知,有未读'; + + @override + String get notificationsOpenUnreadFailure => '通知,有未读的失败'; + @override String get notificationsTitle => '通知'; diff --git a/lib/services/notification_sound/system_alert_sound.dart b/lib/services/notification_sound/system_alert_sound.dart index 589323a1..36b2edfe 100644 --- a/lib/services/notification_sound/system_alert_sound.dart +++ b/lib/services/notification_sound/system_alert_sound.dart @@ -2,11 +2,13 @@ // // NotificationSoundPlayer 只认这个抽象;生产实现 PlatformSystemAlertSound 在 main.dart 装配。 // -// - Windows:winmm 的 PlaySoundW("Notification.Default", SND_ALIAS | SND_ASYNC | SND_NODEFAULT), +// - Windows:winmm 的 PlaySoundW("Notification.Default", SND_ALIAS | SND_ASYNC | SND_NODEFAULT | +// SND_SYSTEM), // 即「声音」控制面板里「通知」那一项——用户换的声音、设成「无」都跟着走。 // SND_NODEFAULT:这一项没设声音时就不响,不退回系统默认的「叮」。 // SND_ASYNC:立即返回,不卡 UI 线程;PlaySound 异步播放时自己复制一份名字, -// 所以调用一返回就可以释放那段 UTF-16。 +// 所以调用一返回就可以释放那段 UTF-16。SND_SYSTEM:归到「系统声音」音频会话,音量合成器里的系统声音 +// 音量 / 静音对它生效。 // - macOS:SystemSound.play(SystemSoundType.alert),引擎侧就是 NSBeep()——用户在 // 「声音 › 提醒音」里选的那个,音量跟「提醒音量」走。 // - 其它平台:没有对应的「用户选的提示音」,不出声。 @@ -50,6 +52,10 @@ class PlatformSystemAlertSound implements SystemAlertSound { static const int _sndNoDefault = 0x0002; static const int _sndAlias = 0x00010000; + /// 归到「系统声音」那一路音频会话:音量合成器里把系统声音静音 / 调小,这里跟着走(设置里写的就是 + /// 「换声音、调音量都在系统声音设置里」)。不带它就按 InkFrame 自己的音量响。 + static const int _sndSystem = 0x00200000; + /// 第一次播放时才打开 winmm.dll、查 PlaySoundW,之后复用。 _PlaySoundDart? _playSound; @@ -72,7 +78,7 @@ class PlatformSystemAlertSound implements SystemAlertSound { final int ok = playSound( alias, nullptr, - _sndAlias | _sndAsync | _sndNoDefault, + _sndAlias | _sndAsync | _sndNoDefault | _sndSystem, ); if (ok == 0) { // 「通知」一项被设成「无」时也会走到这里——那是用户的选择,不算故障,记 info。 diff --git a/lib/theme/components/ink_toast_card.dart b/lib/theme/components/ink_toast_card.dart index 201a5bc7..7b40f817 100644 --- a/lib/theme/components/ink_toast_card.dart +++ b/lib/theme/components/ink_toast_card.dart @@ -137,8 +137,12 @@ class _InkToastCardState extends State { @override void didUpdateWidget(InkToastCard oldWidget) { super.didUpdateWidget(oldWidget); - // 进行中原地变成失败:同样要播报。 - if (widget.tone == InkToastTone.error && oldWidget.tone != InkToastTone.error) { + // 进行中原地变成失败,或去重原地换成了一条新的失败(同一张卡、同一个 State、新原因):都要播报—— + // 否则同一节点先断网失败、再配额失败,读屏用户只听得到第一次。 + if (widget.tone == InkToastTone.error && + (oldWidget.tone != InkToastTone.error || + oldWidget.title != widget.title || + oldWidget.body != widget.body)) { _scheduleAnnounce(); } } diff --git a/test/features/notifications/providers/toast_center_test.dart b/test/features/notifications/providers/toast_center_test.dart index aa3cb2ba..3431afd9 100644 --- a/test/features/notifications/providers/toast_center_test.dart +++ b/test/features/notifications/providers/toast_center_test.dart @@ -324,6 +324,27 @@ void main() { }); }); + test('被折叠的那张换了新内容:挪到最新位置露出来;正在显示的原地换、不跳位', () { + fakeAsync((async) { + final ProviderContainer c = _container(); + for (final String k in ['x', 'a', 'b', 'c']) { + _center(c).show('$k failed', kind: ToastKind.error, dedupeKey: k); + } + final int xId = _state(c).live.first.id; + expect(_state(c).visibleIds, isNot(contains(xId)), reason: '前置:x 已被折叠'); + + _center(c).show('x failed again', kind: ToastKind.error, dedupeKey: 'x'); + expect(_activeTitles(c), ['a failed', 'b failed', 'c failed', 'x failed again']); + expect(_state(c).visibleIds, contains(xId), + reason: '否则提示音响了、铃铛点红了,栈上却看不到任何变化'); + + // c 正在显示:原地换,顺序不动。 + _center(c).show('c failed again', kind: ToastKind.error, dedupeKey: 'c'); + expect(_activeTitles(c), ['a failed', 'b failed', 'c failed again', 'x failed again']); + c.dispose(); + }); + }); + test('替换重新计时', () { fakeAsync((async) { final ProviderContainer c = _container(); @@ -485,6 +506,39 @@ void main() { }); }); + test('清空历史连同折叠在「还有 N 条」里的卡;正在显示的、折叠着的进度卡(连同它那一行历史)照留', () { + fakeAsync((async) { + final ProviderContainer c = _container(); + _center(c).show('working', kind: ToastKind.progress); + _center(c).show('old', kind: ToastKind.error); + for (final String t in ['a', 'b', 'c']) { + _center(c).show(t, kind: ToastKind.error); + } + expect(_state(c).overflowCount, 2, reason: '前置:working 与 old 被折叠'); + + _center(c).clearHistory(); + expect(_activeTitles(c), ['working', 'a', 'b', 'c']); + expect(_state(c).overflowCount, 1); + expect(_historyTitles(c), ['working'], + reason: '「还有 1 条」点开,面板里得看得到它'); + expect(_state(c).unreadCount, 0); + c.dispose(); + }); + }); + + test('铃铛承接不了历史面板时(上面压了对话框 / 没有铃铛),开着的历史立即关掉', () { + fakeAsync((async) { + final ProviderContainer c = _container(); + _center(c).setHistoryHostReady(true); + _center(c).openHistory(); + expect(_state(c).historyOpen, isTrue); + + _center(c).setHistoryHostReady(false); + expect(_state(c).historyOpen, isFalse); + c.dispose(); + }); + }); + test('清空历史不动右下角在场的卡', () { fakeAsync((async) { final ProviderContainer c = _container(); diff --git a/test/features/notifications/widgets/job_notification_listener_test.dart b/test/features/notifications/widgets/job_notification_listener_test.dart index 83b513d7..b9ffcd46 100644 --- a/test/features/notifications/widgets/job_notification_listener_test.dart +++ b/test/features/notifications/widgets/job_notification_listener_test.dart @@ -15,7 +15,7 @@ import 'package:inkframe/core/errors/ink_error.dart'; import 'package:inkframe/core/interfaces/toast_service.dart'; import 'package:inkframe/features/canvas/models/canvas_node.dart'; import 'package:inkframe/features/canvas/providers/canvas_nodes_controller.dart'; -import 'package:inkframe/features/canvas/providers/canvas_selection_controller.dart'; +import 'package:inkframe/features/canvas/providers/canvas_focus_request.dart'; import 'package:inkframe/features/generation/models/job_state.dart'; import 'package:inkframe/features/generation/providers/jobs_registry.dart'; import 'package:inkframe/features/notifications/providers/toast_service_provider.dart'; @@ -154,12 +154,11 @@ void main() { expect(t.primary?.label, _l10n(tester).toastActionView); }); - testWidgets('「查看」:打开产物所在画布(带项目)并选中 result 节点', (tester) async { + testWidgets('「查看」:打开产物所在画布(带项目),挂起定位 result 节点的请求', (tester) async { final _Harness h = await _pump( tester, shell: const ShellState(tab: ShellTab.gallery, project: _alpha), ); - h.container.listen(canvasSelectionControllerProvider('c1'), (_, _) {}); h.upsert(_succeeded('j1')); await tester.pump(); @@ -168,7 +167,12 @@ void main() { expect(s.tab, ShellTab.canvas); expect(s.canvasId, 'c1'); expect(s.project, _alpha); - expect(h.container.read(canvasSelectionControllerProvider('c1')), {'r1'}); + // 不在切画布的同一刻写选中(目标画布还没挂载,写了会被 autoDispose 回收——原先这条用例人为订阅了 + // 选中 provider 保活,所以一直是绿的)。挂起请求,由画布挂上来后落地,见 canvas_focus_request_test。 + expect( + h.container.read(canvasFocusRequestProvider), + const CanvasFocusRequest(canvasId: 'c1', nodeId: 'r1'), + ); }); testWidgets('失败:一张失败卡——本地化原因、提示音、按节点去重', (tester) async { diff --git a/test/features/notifications/widgets/notification_bell_test.dart b/test/features/notifications/widgets/notification_bell_test.dart index b69b70f7..6673a388 100644 --- a/test/features/notifications/widgets/notification_bell_test.dart +++ b/test/features/notifications/widgets/notification_bell_test.dart @@ -2,11 +2,15 @@ // 打开即已读、点外面与 Esc 关闭、ToastCenter.historyOpen 翻成 true 时自己弹开。 // // 只 pump 固定帧:ToastCenter 的倒计时 Timer 在跑,pumpAndSettle 不是这里要的语义。 +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:inkframe/core/di/clock.dart'; import 'package:inkframe/core/interfaces/toast_service.dart'; +import 'package:inkframe/core/logging/logger_service.dart'; import 'package:inkframe/features/notifications/providers/toast_center.dart'; import 'package:inkframe/features/notifications/providers/toast_service_provider.dart'; import 'package:inkframe/features/notifications/widgets/notification_bell.dart'; @@ -15,11 +19,22 @@ import 'package:inkframe/l10n/l10n_x.dart'; import 'package:inkframe/theme/app_theme.dart'; import 'package:inkframe/theme/tokens.dart'; -Future _pumpBell(WidgetTester tester) async { +class _Clock implements Clock { + _Clock(this.value); + DateTime value; + @override + DateTime nowUtc() => value; +} + +Future _pumpBell( + WidgetTester tester, { + List overrides = const [], +}) async { await tester.binding.setSurfaceSize(const Size(1280, 800)); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( ProviderScope( + overrides: overrides, child: MaterialApp( theme: buildAppTheme(variant: InkThemeVariant.dark, textScale: 1), locale: const Locale('en'), @@ -158,4 +173,68 @@ void main() { expect(panel.top, greaterThanOrEqualTo(bell.bottom)); expect(panel.right, moreOrLessEquals(bell.right)); }); + + testWidgets('上面压着对话框时承接不了历史面板:开着的历史关上;对话框关掉后恢复承接', (tester) async { + final ProviderContainer c = await _pumpBell(tester); + await tester.pump(); + expect(c.read(toastCenterProvider).historyHostReady, isTrue, reason: '铃铛挂着、路由在最上层'); + c.read(toastCenterProvider.notifier).openHistory(); + await tester.pump(); + await tester.pump(); + expect(find.byKey(NotificationBell.panelKey), findsOneWidget); + + final BuildContext ctx = tester.element(find.byType(NotificationBell)); + unawaited(showDialog( + context: ctx, + builder: (_) => const AlertDialog(content: Text('dialog')), + )); + for (int i = 0; i < 4; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(c.read(toastCenterProvider).historyHostReady, isFalse); + expect(c.read(toastCenterProvider).historyOpen, isFalse, + reason: '面板挂在铃铛那层路由后面:留着就画在对话框遮罩底下'); + expect(find.byKey(NotificationBell.panelKey), findsNothing); + + Navigator.of(ctx).pop(); + for (int i = 0; i < 4; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(c.read(toastCenterProvider).historyHostReady, isTrue); + }); + + testWidgets('读屏:铃铛的语义标签带上未读 / 未读失败(点只靠颜色,读屏拿不到)', (tester) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + final ProviderContainer c = await _pumpBell(tester); + final AppLocalizations l = _l10n(tester); + expect(find.bySemanticsLabel(l.notificationsOpen), findsOneWidget); + + c.read(toastServiceProvider).show('done', kind: ToastKind.success); + await tester.pump(); + expect(find.bySemanticsLabel(l.notificationsOpenUnread), findsOneWidget); + + c.read(toastServiceProvider).show('boom', kind: ToastKind.error); + await tester.pump(); + expect(find.bySemanticsLabel(l.notificationsOpenUnreadFailure), findsOneWidget); + semantics.dispose(); + }); + + testWidgets('面板开着时相对时间会走:过了两分钟,「刚刚」变成「2 分钟前」', (tester) async { + final _Clock clock = _Clock(DateTime.utc(2026, 10, 15, 12)); + final ProviderContainer c = await _pumpBell( + tester, + overrides: [clockProvider.overrideWithValue(clock)], + ); + final AppLocalizations l = _l10n(tester); + c.read(toastServiceProvider).show('boom', kind: ToastKind.error); + await tester.pump(); + await _tapBell(tester); + expect(find.text(l.notificationsJustNow), findsOneWidget); + + clock.value = clock.value.add(const Duration(minutes: 2)); + await tester.pump(const Duration(seconds: 30)); + expect(find.text(l.notificationsMinutesAgo(2)), findsOneWidget, + reason: '没有新通知也要刷新,否则时间停在打开那一刻'); + }); + } diff --git a/test/features/notifications/widgets/toast_layer_test.dart b/test/features/notifications/widgets/toast_layer_test.dart index 1de70c18..ec20d9b5 100644 --- a/test/features/notifications/widgets/toast_layer_test.dart +++ b/test/features/notifications/widgets/toast_layer_test.dart @@ -113,12 +113,32 @@ void main() { reason: '「还有 N 条」在卡片栈上方', ); + // 铃铛挂着、所在路由在最上层时才承接得了历史面板(本外壳没挂铃铛,这里替它上报)。 + c.read(toastCenterProvider.notifier).setHistoryHostReady(true); + await tester.pump(); await tester.tap(find.byKey(ToastLayer.moreKey)); await tester.pump(); expect(c.read(toastCenterProvider).historyOpen, isTrue, reason: '点它打开铃铛历史'); }); + testWidgets('没有承接得了历史面板的铃铛(启动失败页 / 上面压着对话框):「还有 N 条」点了不开历史,卡片栈照常显示', + (tester) async { + final ProviderContainer c = await _pumpLayer(tester); + for (int i = 0; i < 5; i++) { + _toasts(c).show('e$i'); + } + await tester.pump(); + await tester.pump(_settle); + expect(c.read(toastCenterProvider).historyHostReady, isFalse, reason: '前置:没有铃铛上报'); + + await tester.tap(find.byKey(ToastLayer.moreKey)); + await tester.pump(); + expect(c.read(toastCenterProvider).historyOpen, isFalse, + reason: '否则卡片栈藏起来、计时停住,却没有任何东西能把历史关掉'); + expect(find.byType(InkToastCard), findsNWidgets(3)); + }); + testWidgets('对话框开着时卡片仍在遮罩之上:可命中,点按钮不会落到遮罩上', (tester) async { final ProviderContainer c = await _pumpLayer( diff --git a/test/theme/components/ink_toast_card_test.dart b/test/theme/components/ink_toast_card_test.dart index 0d694c31..4eb80f36 100644 --- a/test/theme/components/ink_toast_card_test.dart +++ b/test/theme/components/ink_toast_card_test.dart @@ -5,6 +5,7 @@ import 'dart:typed_data'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:inkframe/l10n/l10n_x.dart'; import 'package:inkframe/theme/components/ink_toast_card.dart'; @@ -255,4 +256,47 @@ void main() { ); handle.dispose(); }); + + testWidgets('失败卡原地换成一条新的失败(去重替换,State 沿用):读屏再播报一次', (tester) async { + final List announced = []; + tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler( + SystemChannels.accessibility, + (dynamic message) async { + final Map event = message as Map; + if (event['type'] == 'announce') { + announced.add((event['data'] as Map)['message'] as String); + } + return null; + }, + ); + addTearDown(() => tester.binding.defaultBinaryMessenger + .setMockDecodedMessageHandler(SystemChannels.accessibility, null)); + + Widget card(String title, String body) => MediaQuery( + data: const MediaQueryData(supportsAnnounce: true), + child: InkToastCard( + key: const ValueKey('same-card'), + tone: InkToastTone.error, + title: title, + body: body, + dismissLabel: 'Dismiss', + onDismiss: () {}, + ), + ); + + await _pump(tester, (_) => card('Generation failed', 'offline')); + await tester.pump(); + expect(announced, ['Generation failed']); + + // 同一节点又失败了一次,原因不同:同一张卡、同一个 State,只换了内容。 + await _pump(tester, (_) => card('Generation failed', 'quota exceeded')); + await tester.pump(); + expect(announced, hasLength(2), reason: '否则读屏用户只听得到第一次失败'); + + // 内容没变的重建不重复播报。 + await _pump(tester, (_) => card('Generation failed', 'quota exceeded')); + await tester.pump(); + expect(announced, hasLength(2)); + }); + } From 2b07e546bdc101f9791f87dad6a862da95380fca Mon Sep 17 00:00:00 2001 From: Kiro Date: Thu, 15 Oct 2026 04:37:23 +1100 Subject: [PATCH 2/2] =?UTF-8?q?test(notifications):=20=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E5=A4=9A=E4=BD=99=E7=9A=84=20dart:typed=5Fdata=20=E5=AF=BC?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- test/theme/components/ink_toast_card_test.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/theme/components/ink_toast_card_test.dart b/test/theme/components/ink_toast_card_test.dart index 4eb80f36..17b556a2 100644 --- a/test/theme/components/ink_toast_card_test.dart +++ b/test/theme/components/ink_toast_card_test.dart @@ -1,8 +1,6 @@ // InkToastCard:通知卡呈现件的契约(纯数据 + 回调;文案全部由调用方给——theme 层不碰 ARB)。 // // 进行中那条有转圈 / 不确定态动画:全文件只 pump 固定帧,不 pumpAndSettle。 -import 'dart:typed_data'; - import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart';