Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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 调用一次)
Expand Down
66 changes: 60 additions & 6 deletions lib/features/notifications/providers/toast_center.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class ToastCenterState {
this.history = const <ToastEntry>[],
this.unreadIds = const <int>{},
this.historyOpen = false,
this.historyHostReady = false,
});

/// 右下角的卡,旧 → 新;含正在淡出的。
Expand All @@ -76,6 +77,12 @@ class ToastCenterState {
/// 铃铛下拉是否开着。
final bool historyOpen;

/// 铃铛此刻能不能承接历史面板:铃铛挂着、且它所在的路由在最上层(上面没压对话框)。
/// 由铃铛上报(NotificationBell)。不能时卡片栈上「还有 N 条 · 查看全部」不可点——面板挂在根 Overlay
/// 里铃铛那层路由的后面,上面压着对话框时会画在遮罩底下、还抢走对话框的焦点;启动失败页没有铃铛,
/// 开了历史就再没有东西能关掉它(卡片栈一直藏着、计时一直停着)。
final bool historyHostReady;

int get unreadCount => unreadIds.length;

/// 未读里有失败 ⇒ 铃铛上的点用 danger 而不是 accent。
Expand Down Expand Up @@ -120,12 +127,14 @@ class ToastCenterState {
List<ToastEntry>? history,
Set<int>? 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,
);
}

Expand Down Expand Up @@ -215,10 +224,19 @@ class ToastCenter extends Notifier<ToastCenterState> implements ToastService {
dedupeKey: dedupeKey,
dwell: dwell,
);
// 正在显示的那张:原地换内容,不跳位。被折叠的那张:挪到最新的位置露出来——否则提示音响了、铃铛点
// 也红了,卡片栈上却看不到任何变化(一次全新的失败反倒看得见)。
final bool collapsed = !s.visibleIds.contains(old.id);
live = List<ToastEntry>.unmodifiable(
<ToastEntry>[
for (final ToastEntry e in s.live) e.id == old.id ? entry : e,
],
collapsed
? <ToastEntry>[
for (final ToastEntry e in s.live)
if (e.id != old.id) e,
entry,
]
: <ToastEntry>[
for (final ToastEntry e in s.live) e.id == old.id ? entry : e,
],
);
} else {
entry = ToastEntry(
Expand Down Expand Up @@ -277,14 +295,50 @@ class ToastCenter extends Notifier<ToastCenterState> 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<int> visible = s.visibleIds;
final Set<int> keptCollapsed = <int>{
for (final ToastEntry e in s.live)
if (!e.leaving && !visible.contains(e.id) && e.kind == ToastKind.progress) e.id,
};
final List<ToastEntry> live = <ToastEntry>[
for (final ToastEntry e in s.live)
if (e.leaving || visible.contains(e.id) || keptCollapsed.contains(e.id)) e,
];
final List<ToastEntry> history = <ToastEntry>[
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 <ToastEntry>[],
live: List<ToastEntry>.unmodifiable(live),
history: List<ToastEntry>.unmodifiable(history),
unreadIds: const <int>{},
);
_syncTicker();
}

void markAllRead() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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);
});
}

Expand Down
59 changes: 56 additions & 3 deletions lib/features/notifications/widgets/notification_bell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -53,6 +55,12 @@ class _NotificationBellState extends ConsumerState<NotificationBell> {
/// 面板先被「外面」关掉,铃铛的点击又把它打开。
final Object _tapGroup = Object();

/// 上次上报给 ToastCenter 的「能不能承接历史面板」(null = 还没报过)。
bool? _hostReported;

/// 卸载时要报「不能承接」:dispose 里 ref 已不可用,在 initState 取好。
late final ToastCenter _center = ref.read(toastCenterProvider.notifier);

@override
void initState() {
super.initState();
Expand All @@ -79,10 +87,24 @@ class _NotificationBellState extends ConsumerState<NotificationBell> {

@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) {
Expand All @@ -94,6 +116,7 @@ class _NotificationBellState extends ConsumerState<NotificationBell> {

@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),
Expand All @@ -119,7 +142,12 @@ class _NotificationBellState extends ConsumerState<NotificationBell> {
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;
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions lib/features/notifications/widgets/toast_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions lib/l10n/app_zh.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1109,6 +1109,8 @@
"toastMore": "还有 {count} 条 · 查看全部",
"@toastMore": { "placeholders": { "count": { "type": "int" } } },
"notificationsOpen": "通知",
"notificationsOpenUnread": "通知,有未读",
"notificationsOpenUnreadFailure": "通知,有未读的失败",
"notificationsTitle": "通知",
"notificationsEmpty": "还没有通知",
"notificationsClear": "清空",
Expand Down
12 changes: 12 additions & 0 deletions lib/l10n/generated/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions lib/l10n/generated/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
6 changes: 6 additions & 0 deletions lib/l10n/generated/app_localizations_zh.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2882,6 +2882,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get notificationsOpen => '通知';

@override
String get notificationsOpenUnread => '通知,有未读';

@override
String get notificationsOpenUnreadFailure => '通知,有未读的失败';

@override
String get notificationsTitle => '通知';

Expand Down
Loading
Loading