主要变更: 1. 新增桌面端托盘图标支持深色/浅色主题切换 2. 重构应用锁、动画配置、小组件导航服务职责 3. 修复Riverpod初始化断言、防重复点击、工作台模式残留选中态问题 4. 优化诗词服务、阅读进度、搜索结果空状态体验 5. 完善macOS打包配置与错误静默处理逻辑 6. 新增快速卡片多语言适配与动画退出队列管理
342 lines
12 KiB
Dart
342 lines
12 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 窗口关闭处理服务
|
||
/// 创建时间: 2026-06-22
|
||
/// 更新时间: 2026-06-23
|
||
/// 作用: 处理窗口关闭按钮点击,弹出关闭/最小化运行对话框,支持"不再提醒"
|
||
/// 上次更新: 关闭对话框支持点击外侧遮罩关闭(barrierDismissible=true,取消即不操作)
|
||
/// ============================================================
|
||
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:window_manager/window_manager.dart';
|
||
|
||
import '../../../l10n/translation_resolver.dart';
|
||
import '../../../l10n/types/t_settings_desktop.dart';
|
||
import '../../core/storage/kv_storage.dart';
|
||
import '../../core/utils/logger.dart';
|
||
|
||
/// 关闭行为选项
|
||
enum CloseAction {
|
||
/// 完全退出应用(关闭后台)
|
||
quit,
|
||
/// 最小化运行(仅关闭窗口和任务栏图标,保留托盘)
|
||
minimizeToTray,
|
||
}
|
||
|
||
/// 窗口关闭处理服务
|
||
///
|
||
/// 点击窗口栏 X 按钮时:
|
||
/// 1. 若用户已选择"不再提醒",直接执行记住的行为
|
||
/// 2. 否则弹出对话框,让用户选择"关闭"或"最小化运行"
|
||
/// 3. 支持"不再提醒"勾选框,记住用户选择
|
||
class WindowCloseHandler {
|
||
WindowCloseHandler._();
|
||
|
||
// 存储键
|
||
static const String _kCloseActionPref = 'window_close_action';
|
||
static const String _kCloseDontRemind = 'window_close_dont_remind';
|
||
// 上次执行关闭操作的时间戳(ISO8601 字符串),用于 7 天自动恢复提醒
|
||
static const String _kLastActionTime = 'window_close_last_action_time';
|
||
|
||
// 7 天时长(用于自动恢复"不再提醒")
|
||
static const Duration _kRemindResetDuration = Duration(days: 7);
|
||
|
||
/// 处理关闭按钮点击
|
||
///
|
||
/// [context] 用于弹出对话框的 BuildContext,同时用于获取 translationsProvider
|
||
/// [onQuit] 完全退出回调(可选,默认调用 windowManager.destroy)
|
||
/// [onMinimizeToTray] 最小化到托盘回调(可选,默认调用 _minimizeToTray)
|
||
///
|
||
/// 自动恢复提醒:若距上次关闭操作超过 7 天,自动重置"不再提醒",
|
||
/// 让用户重新看到确认对话框(避免长期未使用后忘记已选行为)。
|
||
static Future<void> handleClose({
|
||
required BuildContext context,
|
||
Future<void> Function()? onQuit,
|
||
Future<void> Function()? onMinimizeToTray,
|
||
}) async {
|
||
// 7 天自动恢复提醒:检查上次操作时间,超期则重置"不再提醒"
|
||
await _maybeResetDontRemindAfter7Days();
|
||
|
||
// 读取用户偏好
|
||
final dontRemind = KvStorage.getBool(_kCloseDontRemind) ?? false;
|
||
final actionIndex = KvStorage.getInt(_kCloseActionPref) ?? -1;
|
||
|
||
// 已选择"不再提醒",直接执行记住的行为
|
||
if (dontRemind && actionIndex >= 0) {
|
||
final savedAction = CloseAction.values[actionIndex];
|
||
await _recordLastActionTime();
|
||
await _executeAction(
|
||
action: savedAction,
|
||
onQuit: onQuit,
|
||
onMinimizeToTray: onMinimizeToTray,
|
||
);
|
||
return;
|
||
}
|
||
|
||
// 弹出对话框
|
||
if (!context.mounted) return;
|
||
|
||
// 通过 ProviderScope 获取 translations(handleClose 为静态方法)
|
||
final t = ProviderScope.containerOf(context, listen: false)
|
||
.read(translationsProvider)
|
||
.settings
|
||
.desktop;
|
||
|
||
final result = await _showCloseDialog(context, t);
|
||
if (result == null) return; // 用户取消
|
||
|
||
final (action, remember) = result;
|
||
|
||
// 记住用户选择
|
||
if (remember) {
|
||
await KvStorage.setInt(_kCloseActionPref, action.index);
|
||
await KvStorage.setBool(_kCloseDontRemind, true);
|
||
}
|
||
|
||
// 记录本次操作时间(用于 7 天自动恢复判断)
|
||
await _recordLastActionTime();
|
||
|
||
// 执行行为
|
||
await _executeAction(
|
||
action: action,
|
||
onQuit: onQuit,
|
||
onMinimizeToTray: onMinimizeToTray,
|
||
);
|
||
}
|
||
|
||
/// macOS ⌘Q 快捷键处理入口
|
||
///
|
||
/// 项目中 macOS 原生菜单栏使用 `PlatformProvidedMenuItemType.quit`,
|
||
/// 系统 ⌘Q 默认直接退出应用,不会经过关闭确认流程。
|
||
/// 此方法供 app.dart / main.dart 在自定义快捷键监听中调用,
|
||
/// 让 ⌘Q 也走 [handleClose] 的关闭确认流程(含"不再提醒"与 7 天重置)。
|
||
///
|
||
/// 使用示例(在根 widget 的 KeyboardListener 中):
|
||
/// ```dart
|
||
/// if (HardwareKeyboard.instance.isMetaPressed &&
|
||
/// event.logicalKey == LogicalKeyboardKey.keyQ) {
|
||
/// await WindowCloseHandler.handleCmdQ(context);
|
||
/// }
|
||
/// ```
|
||
static Future<void> handleCmdQ(BuildContext context) {
|
||
return handleClose(context: context);
|
||
}
|
||
|
||
/// 7 天自动恢复提醒
|
||
///
|
||
/// 读取上次关闭操作时间,若距今超过 [_kRemindResetDuration](7 天),
|
||
/// 则将 `window_close_dont_remind` 重置为 false,让用户重新看到确认对话框。
|
||
/// 这样长时间未使用后,用户不会因遗忘的旧选择而困惑。
|
||
static Future<void> _maybeResetDontRemindAfter7Days() async {
|
||
try {
|
||
final lastTimeStr = KvStorage.getString(_kLastActionTime);
|
||
if (lastTimeStr == null || lastTimeStr.isEmpty) return;
|
||
|
||
final lastTime = DateTime.tryParse(lastTimeStr);
|
||
if (lastTime == null) return;
|
||
|
||
final elapsed = DateTime.now().difference(lastTime);
|
||
if (elapsed >= _kRemindResetDuration) {
|
||
await KvStorage.setBool(_kCloseDontRemind, false);
|
||
Log.i('WindowCloseHandler: 距上次关闭操作已 ${elapsed.inDays} 天,'
|
||
'自动恢复"不再提醒"');
|
||
}
|
||
} catch (e, st) {
|
||
Log.e('WindowCloseHandler._maybeResetDontRemindAfter7Days 失败', e, st);
|
||
}
|
||
}
|
||
|
||
/// 记录本次关闭操作时间(ISO8601 字符串)
|
||
static Future<void> _recordLastActionTime() async {
|
||
try {
|
||
await KvStorage.setString(
|
||
_kLastActionTime,
|
||
DateTime.now().toIso8601String(),
|
||
);
|
||
} catch (e, st) {
|
||
Log.e('WindowCloseHandler._recordLastActionTime 失败', e, st);
|
||
}
|
||
}
|
||
|
||
/// 弹出关闭确认对话框
|
||
///
|
||
/// [t] 桌面端翻译文案(TSettingsDesktop)
|
||
/// 返回 (CloseAction, bool remember),用户取消返回 null
|
||
static Future<(CloseAction, bool)?> _showCloseDialog(
|
||
BuildContext context,
|
||
TSettingsDesktop t,
|
||
) async {
|
||
bool dontRemind = false;
|
||
|
||
return showCupertinoDialog<(CloseAction, bool)?>(
|
||
context: context,
|
||
barrierDismissible: true,
|
||
builder: (ctx) {
|
||
return StatefulBuilder(
|
||
builder: (ctx, setDialogState) {
|
||
return CupertinoAlertDialog(
|
||
title: Text(t.windowCloseTitle),
|
||
content: Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(t.windowCloseMessage),
|
||
const SizedBox(height: 12),
|
||
// 不再提醒勾选框
|
||
GestureDetector(
|
||
onTap: () => setDialogState(() {
|
||
dontRemind = !dontRemind;
|
||
}),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 12,
|
||
vertical: 8,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: CupertinoColors.systemGrey6.resolveFrom(ctx),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
dontRemind
|
||
? CupertinoIcons.checkmark_square_fill
|
||
: CupertinoIcons.square,
|
||
size: 20,
|
||
color: dontRemind
|
||
? CupertinoColors.systemGreen
|
||
: CupertinoColors.systemGrey,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
t.windowCloseDontRemind,
|
||
style: const TextStyle(fontSize: 14),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
actions: [
|
||
CupertinoDialogAction(
|
||
isDestructiveAction: true,
|
||
onPressed: () {
|
||
Navigator.pop(ctx, (CloseAction.quit, dontRemind));
|
||
},
|
||
child: Text(t.windowCloseAction),
|
||
),
|
||
CupertinoDialogAction(
|
||
isDefaultAction: true,
|
||
onPressed: () {
|
||
Navigator.pop(
|
||
ctx,
|
||
(CloseAction.minimizeToTray, dontRemind),
|
||
);
|
||
},
|
||
child: Text(t.windowMinimizeAction),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 执行关闭行为
|
||
static Future<void> _executeAction({
|
||
required CloseAction action,
|
||
Future<void> Function()? onQuit,
|
||
Future<void> Function()? onMinimizeToTray,
|
||
}) async {
|
||
try {
|
||
switch (action) {
|
||
case CloseAction.quit:
|
||
// 完全退出应用:Win 和 Mac 都关闭后台
|
||
if (onQuit != null) {
|
||
await onQuit();
|
||
} else {
|
||
await windowManager.destroy();
|
||
}
|
||
break;
|
||
case CloseAction.minimizeToTray:
|
||
// 最小化运行:仅关闭窗口和任务栏图标,保留托盘
|
||
if (onMinimizeToTray != null) {
|
||
await onMinimizeToTray();
|
||
} else {
|
||
await _minimizeToTray();
|
||
}
|
||
break;
|
||
}
|
||
} catch (e, st) {
|
||
Log.e('WindowCloseHandler._executeAction 失败', e, st);
|
||
}
|
||
}
|
||
|
||
/// 最小化到托盘
|
||
///
|
||
/// 使用 minimize + setSkipTaskbar 组合替代 hide(),
|
||
/// 因为 windowManager.hide() 在 macOS 上与 macos_window_utils 的
|
||
/// NSVisualEffectView + titlebarAppearsTransparent 组合会导致原生崩溃。
|
||
static Future<void> _minimizeToTray() async {
|
||
try {
|
||
await windowManager.setSkipTaskbar(true);
|
||
await windowManager.minimize();
|
||
Log.i('窗口已最小化到托盘');
|
||
} catch (e, st) {
|
||
Log.e('WindowCloseHandler._minimizeToTray 失败', e, st);
|
||
// 回退:直接最小化
|
||
try {
|
||
await windowManager.minimize();
|
||
} catch (e2) {
|
||
Log.e('WindowCloseHandler 回退最小化也失败: $e2');
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 重置关闭偏好(供设置页面调用)
|
||
///
|
||
/// 同时清理:关闭行为选择、"不再提醒"标记、上次操作时间戳。
|
||
static Future<void> resetPreference() async {
|
||
await KvStorage.remove(_kCloseActionPref);
|
||
await KvStorage.remove(_kCloseDontRemind);
|
||
await KvStorage.remove(_kLastActionTime);
|
||
}
|
||
|
||
/// 设置关闭偏好(供设置页面调用)
|
||
///
|
||
/// [action] 关闭行为:
|
||
/// - 非 null:保存指定行为
|
||
/// - null:清除已保存的行为,回到"每次询问"状态
|
||
/// [dontRemind] 是否不再提醒
|
||
static Future<void> setPreference({
|
||
CloseAction? action,
|
||
required bool dontRemind,
|
||
}) async {
|
||
if (action != null) {
|
||
await KvStorage.setInt(_kCloseActionPref, action.index);
|
||
} else {
|
||
// 清除已保存的关闭行为,回到"每次询问"状态
|
||
await KvStorage.remove(_kCloseActionPref);
|
||
}
|
||
await KvStorage.setBool(_kCloseDontRemind, dontRemind);
|
||
}
|
||
|
||
/// 获取当前关闭偏好
|
||
///
|
||
/// 返回 (CloseAction?, bool dontRemind)
|
||
/// CloseAction 为 null 表示未设置
|
||
static (CloseAction?, bool) getPreference() {
|
||
final dontRemind = KvStorage.getBool(_kCloseDontRemind) ?? false;
|
||
final actionIndex = KvStorage.getInt(_kCloseActionPref) ?? -1;
|
||
if (actionIndex < 0 || actionIndex >= CloseAction.values.length) {
|
||
return (null, dontRemind);
|
||
}
|
||
return (CloseAction.values[actionIndex], dontRemind);
|
||
}
|
||
}
|