From 83720002e6e7c5ea7232097df794c9c5e2dd1e49 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 19 Jun 2026 06:43:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8F=B0=E6=A8=A1=E5=BC=8F=E3=80=81=E7=B3=BB=E7=BB=9F=E6=89=98?= =?UTF-8?q?=E7=9B=98=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=A4=9A=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=80=A7=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增工作台三栏布局模式,适配宽屏设备 2. 添加跨平台系统托盘支持,新增托盘图标资源 3. 修复工作台模式下导航返回异常问题 4. 统一JSON类型安全解析,替换硬类型转换 5. 增加macOS深度链接支持,统一渠道分发信息 6. 优化部分页面生命周期和状态加载逻辑 7. 移除废弃的nearby_connections依赖 --- CHANGELOG.md | 652 +++++----- Scripts/generate_tray_icons.py | 125 ++ assets/images/tray_icon_dark.png | Bin 0 -> 143 bytes assets/images/tray_icon_dark_32.png | Bin 0 -> 188 bytes assets/images/tray_icon_light.png | Bin 0 -> 134 bytes assets/images/tray_icon_light_32.png | Bin 0 -> 183 bytes assets/svgs/tray_icon.svg | 22 + docs/design-preview/pc-workspace-layout.html | 1111 +++++++++++++++++ docs/desktop_native_enhancement_spec.md | 949 ++++++++++++++ docs/pc-workbench-spec.md | 236 ++++ docs/toolsapi/API_ANALYSIS.md | 699 ----------- iOS_macOS_Developer_Guide.md | 9 + ios/Runner/Info.plist | 3 +- lib/app/app.dart | 102 +- lib/app/layout/adaptive_nav_bar.dart | 92 +- lib/app/layout/app_shell.dart | 191 +-- lib/app/layout/overview_dashboard.dart | 15 +- lib/core/layout/adaptive_split_view.dart | 30 +- lib/core/layout/panel_bookmark.dart | 90 -- lib/core/layout/right_panel_registry.dart | 67 - .../layout/split_view_navigation_mixin.dart | 40 +- lib/core/layout/triple_column_view.dart | 73 -- .../workbench/right_panel_navigator.dart | 240 ++++ .../layout/workbench/workbench_layout.dart | 419 +++++++ lib/core/models/user_model.dart | 51 +- lib/core/network/cache_config.dart | 15 +- lib/core/providers/split_view_provider.dart | 185 ++- .../split_view_provider.freezed.dart | 145 ++- lib/core/router/app_nav_extension.dart | 224 +++- lib/core/router/app_routes.dart | 1 + lib/core/router/route_def.dart | 11 +- lib/core/router/route_registry.dart | 87 +- .../services/auth/permission_service.dart | 7 +- .../background/background_task_service.dart | 6 + .../services/catcher2_config_service.dart | 14 +- .../data/image_cache_metadata_service.dart | 7 +- .../daily_sentence_viewed_service.dart | 118 ++ .../desktop/desktop_service_registry.dart | 63 + .../desktop/desktop_tray_menu_builder.dart | 284 +++++ .../desktop/desktop_tray_service.dart | 210 ++++ .../desktop_window_effect_service.dart | 77 ++ .../macos_window_effect_service.dart | 115 ++ .../tray_manager_tray_service.dart | 211 ++++ .../windows_acrylic_service.dart | 164 +++ .../device/macos_platform_service.dart | 105 +- .../device/windows_platform_service.dart | 62 +- .../services/network/deep_link_service.dart | 43 +- .../notification/notification_center.dart | 17 +- lib/core/storage/database/app_database.dart | 38 +- lib/core/storage/kv_storage.dart | 24 +- lib/core/utils/platform/clipboard_bridge.dart | 178 ++- .../presentation/article_detail_page.dart | 7 +- .../presentation/article_edit_page.dart | 7 +- .../auth/presentation/qrcode_login_page.dart | 8 +- .../correction/correction_provider.dart | 13 +- .../pages/ctc_note_list_page.dart | 7 +- .../ctc/providers/ctc_note_provider.dart | 13 +- lib/features/ctc/services/ctc_api_client.dart | 9 +- .../daily_card/daily_card_provider.dart | 58 +- .../presentation/daily_card_page.dart | 65 +- .../desktop/desktop_tray_controller.dart | 360 ++++++ .../desktop/desktop_window_title_bar.dart | 502 ++++++++ .../desktop/macos_menu_bar_wrapper.dart | 445 +++++++ .../pages/chat/chat_flow_page.dart | 14 +- .../pages/chat/chat_settings_page.dart | 37 +- .../pages/chat/hidden_sessions_page.dart | 2 +- .../pages/content/category_detail_page.dart | 7 +- .../pages/home/discover_page.dart | 110 +- .../pages/home/inspiration_detail_sheet.dart | 34 +- .../presentation/panels/chat_flow_panel.dart | 6 +- .../widgets/chat/chat_flow_input_bar.dart | 24 +- .../widgets/chat/chat_flow_message_list.dart | 10 +- .../chat_flow_readlater_settings_helper.dart | 17 +- .../chat/chat_flow_readlater_sync_helper.dart | 17 +- .../widgets/chat/chat_flow_send_toast.dart | 47 +- .../chat_bubble/chat_document_bubble.dart | 41 +- .../widgets/chat_bubble/chat_file_bubble.dart | 11 +- .../chat_bubble/chat_image_bubble.dart | 36 +- .../widgets/chat_bubble/chat_link_bubble.dart | 27 +- .../chat_bubble/chat_location_bubble.dart | 20 +- .../chat_sentence_card_bubble.dart | 88 +- .../chat_bubble/chat_video_bubble.dart | 43 +- .../chat_input/attachment_grid_sheet.dart | 17 +- .../widgets/chat_input/link_input_sheet.dart | 23 +- .../chat_input/location_input_sheet.dart | 32 +- .../chat_input/record_audio_sheet.dart | 28 +- .../chat_input/rich_text_editor_sheet.dart | 23 +- .../widgets/session/session_row.dart | 7 + .../widgets/tool/tool_bottom_bar.dart | 6 +- .../widgets/tool/tool_center_right_panel.dart | 107 ++ .../presentation/widgets/tool/tool_panel.dart | 7 +- .../providers/chat_session_provider.dart | 9 +- .../screen_share/input_action.dart | 9 +- .../screen_share/screen_share_page.dart | 11 +- .../models/cloud_cache_record.dart | 16 +- .../file_transfer/models/transfer_task.dart | 7 +- .../pages/device_pairing_page.dart | 7 +- .../presentation/pages/qr_code_tab.dart | 11 +- .../services/cloud_cache_service.dart | 7 +- .../discovery/usb_discovery_service.dart | 2 +- .../services/signaling_service.dart | 9 +- .../transport/usb_transport_service.dart | 2 +- lib/features/home/favorite_repository.dart | 18 +- .../presentation/anonymous_submit_page.dart | 9 +- lib/features/home/presentation/home_page.dart | 41 +- .../readlater/readlater_provider.dart | 84 +- .../readlater/tray_unread_count_provider.dart | 99 ++ .../providers/readlater_page.dart | 10 +- .../widgets/new_features_dialog.dart | 605 +++++++++ .../providers/home_interaction_mixin.dart | 14 +- .../home/services/offline_manager.dart | 7 +- .../member/presentation/member_page.dart | 7 +- .../note/presentation/note_edit_page.dart | 225 +++- .../onboarding/onboarding_provider.dart | 11 + .../pages/personalization_page.dart | 12 + .../poetry/presentation/poetry_page.dart | 120 +- .../presentation/poetry_settings_page.dart | 7 +- lib/features/pomodoro/pomodoro_core.dart | 7 +- .../profile/presentation/profile_page.dart | 33 +- .../spotlight_search_provider.dart | 7 +- .../reading_report/reading_report_core.dart | 143 ++- .../search/presentation/search_page.dart | 6 +- .../account/security_question_page.dart | 13 +- .../experimental_features_page.dart | 105 +- .../general/general_settings_page.dart | 5 + .../general/general_settings_sections.dart | 37 +- .../presentation/image_cache_models.dart | 9 +- .../presentation/panels/settings_panels.dart | 872 ------------- .../plugin/translate_plugin_page.dart | 10 +- .../presentation/plugin/tts_plugin_page.dart | 13 +- .../presentation/privacy/crash_log_page.dart | 7 +- .../theme/theme_sections_preview.dart | 11 +- .../workbench/workbench_settings_page.dart | 410 ++++++ .../services/settings_change_logger.dart | 7 +- lib/features/solar_term/solar_term_core.dart | 29 +- .../presentation/study_plan_page.dart | 6 +- .../providers/reading_goal_provider.dart | 140 ++- lib/features/task/daily_task_page.dart | 7 +- lib/features/task/task_core.dart | 23 +- .../template/models/template_models.dart | 38 +- .../services/wallpaper_health_service.dart | 9 +- .../wallpaper_gallery_view.dart | 173 ++- .../wallpaper_source_bar.dart | 40 +- .../leisure/models/leisure_card.dart | 9 +- .../pages/leisure_timeline_page.dart | 8 +- .../presentation/statistics_page.dart | 33 +- .../providers/user_stats_provider.dart | 23 +- .../models/user_center_models.dart | 14 +- .../presentation/learning_center_page.dart | 19 +- .../presentation/learning_progress_page.dart | 11 +- .../presentation/public_profile_page.dart | 7 +- .../user_center/providers/coin_provider.dart | 7 +- .../providers/interaction_provider.dart | 9 +- .../services/user_center_service.dart | 19 +- .../presentation/weather_settings_page.dart | 7 +- lib/features/weather/weather_provider.dart | 9 +- lib/features/widget/widget_provider.dart | 8 + lib/l10n/languages/ar.dart | 5 +- lib/l10n/languages/bn.dart | 5 +- lib/l10n/languages/de.dart | 5 +- lib/l10n/languages/en.dart | 5 +- lib/l10n/languages/es.dart | 5 +- lib/l10n/languages/fr.dart | 5 +- lib/l10n/languages/hi.dart | 5 +- lib/l10n/languages/it.dart | 5 +- lib/l10n/languages/ja.dart | 5 +- lib/l10n/languages/ko.dart | 5 +- lib/l10n/languages/pt.dart | 5 +- lib/l10n/languages/ru.dart | 5 +- lib/l10n/languages/zh_cn.dart | 5 +- lib/l10n/languages/zh_tw.dart | 5 +- lib/l10n/types/t_onboarding.dart | 8 + lib/l10n/types/t_settings_display.dart | 16 + lib/main.dart | 16 + .../adaptive/adaptive_back_button.dart | 9 +- .../widgets/display/appbar_date_display.dart | 7 +- lib/shared/widgets/display/themed_icon.dart | 214 ++++ lib/shared/widgets/input/setting_row.dart | 77 +- lib/shared/widgets/qrcode_scanner_page.dart | 7 +- linux/flutter/generated_plugin_registrant.cc | 8 + linux/flutter/generated_plugins.cmake | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 8 +- macos/Podfile.lock | 43 +- .../xcshareddata/xcschemes/Runner.xcscheme | 11 +- macos/Runner/AppDelegate.swift | 132 +- macos/Runner/Info.plist | 12 + macos/Runner/MainFlutterWindow.swift | 277 +++- pubspec.macos.yaml | 4 +- pubspec.ohos.yaml | 9 +- .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + windows/runner/flutter_window.cpp | 64 +- windows/runner/win32_window.cpp | 130 ++ windows/runner/win32_window.h | 40 + 194 files changed, 11716 insertions(+), 3120 deletions(-) create mode 100644 Scripts/generate_tray_icons.py create mode 100644 assets/images/tray_icon_dark.png create mode 100644 assets/images/tray_icon_dark_32.png create mode 100644 assets/images/tray_icon_light.png create mode 100644 assets/images/tray_icon_light_32.png create mode 100644 assets/svgs/tray_icon.svg create mode 100644 docs/design-preview/pc-workspace-layout.html create mode 100644 docs/desktop_native_enhancement_spec.md create mode 100644 docs/pc-workbench-spec.md delete mode 100644 docs/toolsapi/API_ANALYSIS.md delete mode 100644 lib/core/layout/panel_bookmark.dart delete mode 100644 lib/core/layout/right_panel_registry.dart delete mode 100644 lib/core/layout/triple_column_view.dart create mode 100644 lib/core/layout/workbench/right_panel_navigator.dart create mode 100644 lib/core/layout/workbench/workbench_layout.dart create mode 100644 lib/core/services/desktop/daily_sentence_viewed_service.dart create mode 100644 lib/core/services/desktop/desktop_service_registry.dart create mode 100644 lib/core/services/desktop/desktop_tray_menu_builder.dart create mode 100644 lib/core/services/desktop/desktop_tray_service.dart create mode 100644 lib/core/services/desktop/desktop_window_effect_service.dart create mode 100644 lib/core/services/desktop/implementations/macos_window_effect_service.dart create mode 100644 lib/core/services/desktop/implementations/tray_manager_tray_service.dart create mode 100644 lib/core/services/desktop/implementations/windows_acrylic_service.dart create mode 100644 lib/features/desktop/desktop_tray_controller.dart create mode 100644 lib/features/desktop/desktop_window_title_bar.dart create mode 100644 lib/features/desktop/macos_menu_bar_wrapper.dart create mode 100644 lib/features/discover/presentation/widgets/tool/tool_center_right_panel.dart create mode 100644 lib/features/home/presentation/providers/readlater/tray_unread_count_provider.dart create mode 100644 lib/features/home/presentation/widgets/new_features_dialog.dart delete mode 100644 lib/features/settings/presentation/panels/settings_panels.dart create mode 100644 lib/features/settings/presentation/workbench/workbench_settings_page.dart create mode 100644 lib/shared/widgets/display/themed_icon.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index e29a0af1..3b17ba17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,351 +2,397 @@ 所有重要变更均记录于此文件。格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 -> 保留最近 10 个版本(v6.80.0 ~ v6.89.0)。更早版本已归档至软件特性功能文档。 +> 保留最近 10 个版本(v6.89.1 ~ v6.94.3)。更早版本(v6.87.0 ~ v6.89.0)的特性已合并进软件特性功能文档,详见各版本条目。 *** -## [v6.89.0] - 2026-06-17 +## [v6.94.4] - 2026-06-19 -### 🛠 评分弹窗商店名称多语言 + Beta问卷按钮隐藏跨平台修复 +### 🐛 修复 -#### 1. "给个好评"弹窗商店名称由硬编码"Google Play"改为多语言"应用商店" -- **需求**: Android 端点击"给个好评"后,跳转确认弹窗显示硬编码英文"Google Play",未接入多语言系统,风格不统一 -- **实现**: - - `t_about.dart` 新增 `appStore` 翻译字段(构造函数 + 字段定义 + toMap + fromMap) - - 14 种语言文件全部补充 `appStore` 翻译: - - zh_cn: 应用商店 / zh_tw: 應用商店 / en: App Store / ru: Магазин приложений - - pt: Loja de aplicativos / ko: 앱 스토어 / ja: アプリストア / it: App Store - - hi: ऐप स्टोर / fr: App Store / es: Tienda de aplicaciones / de: App Store - - bn: অ্যাপ স্টোর / ar: متجر التطبيقات - - `app_store_service.dart` 的 `getStoreName` 方法:Android 分支由 `return 'Google Play'` 改为 `return t.about.appStore` -- **影响**: `profile_page.dart` 和 `about_page.dart` 的"给个好评"/"评价应用"入口弹窗文案自动跟随系统语言 - -#### 2. Beta 页面"填写问卷"按钮提交后隐藏逻辑跨平台生效 -- **问题**: 问卷提交后按钮只在 iOS 端隐藏,Android 等端不隐藏 -- **根因**: - 1. `_QuestionnaireSheet` 不符合条件关闭时调用 `_markQuestionnaireSubmitted()` 未 `await`,`Navigator.pop` 可能在 SharedPreferences 写入完成前执行 - 2. Sheet 关闭后仅依赖 `_loadQuestionnaireSubmitted()` 异步重读 SharedPreferences 更新 UI,跨平台存在时序差异(iOS NSUserDefaults 内存同步快,Android SharedPreferences.apply 异步落盘) -- **修复**: - - `_QuestionnaireSheet` 的两处关闭按钮改为 `Navigator.pop(context, true)` 返回已提交标记 - - `_showQuestionnaire` 接收 Sheet 返回值,`submitted == true` 时立即 `setState` 更新 `_questionnaireSubmitted`,不再依赖 SharedPreferences 时序 - - 不符合条件关闭分支改为 `async` + `await _markQuestionnaireSubmitted()`,并在 await 前获取 `Navigator.of(context)` 避免 `use_build_context_synchronously` - - 保留 `_loadQuestionnaireSubmitted()` 作为兜底,确保与持久化状态最终一致 -- **效果**: 所有平台(iOS/Android/Windows/macOS)问卷提交后按钮立即隐藏 - -#### 修改文件 -| 文件 | 变更 | -|---|---| -| `l10n/types/t_about.dart` | 新增 `appStore` 字段(构造/定义/toMap/fromMap) | -| `l10n/languages/*.dart` (14 个) | 补充 `appStore` 多语言翻译 | -| `core/services/app_store_service.dart` | Android 端商店名称改用 `t.about.appStore` | -| `features/settings/presentation/experimental_features_page.dart` | 问卷 Sheet 返回值即时更新 UI + await 修复时序 | +#### 工作台布局修复(3处) +- **Issue1: 拖拽宽度 clamp 逻辑冗余且极端小屏可能抛异常**:`_onDragUpdate` 两次 clamp 冗余(先 clamp 到屏幕允许值,再 clamp 到硬编码 `_maxMiddleWidth`),且极端小屏(`screenMax < _minMiddleWidth`)时第一次 clamp 上限 < 下限会抛 `ArgumentError`。 + - **说明**:原 Issue 描述"第二次 clamp 反向扩大宽度导致溢出"有误——Dart `clamp` 不会扩大值,只限制值。真正问题是冗余 + 极端小屏异常。 + - **修复**:合并为单次 clamp,取 `screenMax` 与 `_maxMiddleWidth` 的较小值作为有效上限,并用 `safeMax` 保护下限(避免 `ArgumentError`) + - **文件**:lib/core/layout/workbench/workbench_layout.dart +- **Issue2: 双栏模式注释与实现不符**:注释称"隐藏中栏",但实际代码显示中栏(宽度*0.75)。 + - **修复**:更新注释为"中栏紧凑显示(宽度*0.75)+ 分割条 + 右栏",匹配实际实现 + - **文件**:lib/core/layout/workbench/workbench_layout.dart +- **Issue3: pop 操作未检查 canPop 导致根级页面被意外弹空**:`pop` 仅检查 `entries.isEmpty`,栈深 1 时 `canPop=false` 但 pop 仍执行 `removeLast` 导致栈变空,与 `canPop` 判断不一致。 + - **修复**:`pop` 改为 `if (!currentStack.canPop) return`,与 `canPop` 保持一致 + - **联动修复**:v6.94.2 的 Issue3(构建失败移除无效条目)原直接调用 `pop`,在栈深 1 时会因 canPop 检查不执行导致无效条目残留。改为 `canPop` 时 `pop`、否则 `clear`(栈深 1 的无效条目即根级,清空回仪表盘) + - **文件**:lib/core/layout/workbench/right_panel_navigator.dart、lib/core/layout/workbench/workbench_layout.dart *** -## [v6.88.0] - 2026-06-17 +## [v6.94.3] - 2026-06-19 -### ✨ 闲情逸致价格档位扩展 + 纠错历史本地缓存 + 学习计划详情页 +### 🐛 修复 -#### 1. 价格档位从单一"平价"扩展为"平价/中等/高档"三档 -- **需求**: 原价格仅"平价/付费/商业/未知"四档,无法精准区分中等与高档消费 -- **实现**: - - `LeisurePriceType` 枚举从 4 值扩展为 6 值:`budget`(平价🆓) / `mid`(中等💰) / `premium`(高档💎) / `paid`(付费💳) / `commercial`(商业🏪) / `unknown`(未知❓) - - 新增 `fromIdCompat` 静态方法,将旧 `free` ID 兼容映射为 `budget`,保证存量数据可读 - - `label` 字段改为 `labelKey`(翻译键),避免硬编码中文 - - `priceLabel`/`heatLabel` 标记 deprecated,新增 `priceLabelKey`/`heatLabelKey` -- **影响**: `leisure_card.dart`、`leisure_card_row.dart`、`leisure_card_detail_sheet.dart`、`leisure_bottom_filter.dart` +#### 工作台模式白屏问题(搜索取消导致根栈弹空) +- **Issue: 仪表盘点搜索→取消后白屏**:工作台模式下,搜索页通过 `rightPanelStackProvider` 推入右栏嵌套栈渲染(非 GoRouter Navigator),但页面内取消按钮调用 `Navigator.pop(context)` 误弹根 GoRouter 栈,触发 `currentConfiguration.isNotEmpty` 断言错误,导致白屏。 + - **根因**:右栏页面直接使用 `Navigator.pop(context)` 操作根 GoRouter Navigator,而非右栏嵌套栈 + - **修复**:在 `AppNavExtension` 新增工作台感知的 `appPop()`/`appCanPop()` 方法(宽屏工作台模式 pop 右栏栈,否则回退 `Navigator.pop`);同步在 `WorkbenchNavExtension` 新增 `WidgetRef` 版本 + - **文件**:lib/core/router/app_nav_extension.dart +- **AdaptiveBackButton 工作台感知**:统一返回按钮组件接入 `appCanPop`/`appPop`,正确感知右栏栈状态 + - **文件**:lib/shared/widgets/adaptive/adaptive_back_button.dart +- **SearchPage 取消按钮**:改用 `context.appPop()` 替代 `Navigator.pop(context)` + - **文件**:lib/features/search/presentation/search_page.dart +- **举一反三(12处页面级返回按钮)**:批量修复 member_page、category_detail_page、article_edit_page、article_detail_page、screen_share_page、crash_log_page、ctc_note_list_page、tool_bottom_bar、qrcode_login_page、qr_code_tab、device_pairing_page、study_plan_page 的页面级返回按钮(仅页面级,对话框/sheet 内的 pop 保持不变) + - **文件**:见上述各文件 -#### 2. 价格筛选联动数量统计 -- **需求**: 筛选时用户无法预知各档位有多少卡片匹配 -- **实现**: - - `leisure_bottom_filter.dart` 新增 `_computeFilterCounts` 方法,从 `leisureTimelineProvider` 遍历所有节点卡片 - - 9 个筛选项(花期/美食/高海拔/风险/平价/中等/高档/日出/观海)均显示匹配数量角标 - - 数量为 0 时不显示角标,避免视觉噪音 - - 修复 `node.foodCards`/`node.playCards` 不存在的 bug,改用 `node.allCards` 扩展 getter - -#### 3. 闲情逸致全模块多语言支持 -- **需求**: "平价"等价格标签仍为硬编码中文,未接入翻译系统 -- **实现**: - - 新建 `t_leisure.dart` 翻译类型,包含 25 个字段(价格档位/筛选标签/热度等级/重构提示) - - 接入 `t_root.dart`、`t.dart`、`translation_io_service.dart` - - 14 种语言文件全部补充 `leisure` 节点翻译 - - `leisure_card.dart` 的 `_buildPriceBadge` 使用 `TLeisure` 翻译 - - `leisure_card_detail_sheet.dart` 新增 `_priceLabel` 方法替代 `priceType.label` - - `leisure_bottom_filter.dart` 使用 `_FilterDef` + `labelGetter` 从 `TLeisure` 获取多语言文案 - -#### 4. 纠错历史本地缓存(drift) -- **需求**: 纠错历史每次打开都实时请求服务器,无离线查看能力 -- **实现**: - - `app_database.dart` 新增 `CorrectionRecords` 表(15 列:type/sourceType/sourceId/content/username/email/sourceUrl/switchVal/isLocal/isAnonymous/isSynced/createtime/localCreatedAt/updatedAt) - - schemaVersion 从 19 升至 20,新增 `_migrateToV20` 迁移方法(建表 + 时间倒序索引) - - `AppDatabase` 新增 7 个 CRUD 方法:`insertCorrectionRecord`/`insertCorrectionRecords`/`getCorrectionRecords`/`deleteCorrectionRecord`/`clearCorrectionRecords`/`getCorrectionRecordCount`/`replaceCorrectionRecords` - - `correction_provider.dart` 重构: - - 新增 `CorrectionItem` 视图模型,统一本地与服务端字段(`fromServerMap`/`fromDb`/`toCompanion`) - - `CorrectionState` 新增 `isLoadingFromCache`/`isSyncing` 状态 - - `build()` 启动时异步加载本地缓存 - - `loadCorrections()` 改为"先读本地 → 立即渲染 → 再请求服务器 → 全量替换本地" - - `submitCorrection()` 成功后写入本地;网络异常时也写入本地(标记 `isSynced=false`) - - 新增 `clearLocalCache()` 方法 - - `correction_page.dart` 的 `_buildRecordItem` 改用 `CorrectionItem` 强类型 - - 因项目 `dart_style`/`freezed` 与 `analyzer 12.1.0` 不兼容导致 `build_runner` 无法运行,手动编写 `app_database.g.dart` 中的 `$CorrectionRecordsTable`/`CorrectionRecord`/`CorrectionRecordsCompanion` 三个类 - -#### 5. 学习计划预告弹窗"了解详情"详情页 -- **需求**: 预告弹窗仅文字提示,用户无法了解具体变更内容 -- **实现**: - - `t_study_plan.dart` 新增 3 个翻译字段:`restructureDetails`/`restructureDetailsTitle`/`restructureDetailsBody` - - 14 种语言文件全部补充 studyPlan 的 3 个新字段 - - `discover_page.dart` 弹窗新增"了解详情"按钮(三按钮布局:不再提醒/了解详情/确定) - - 新增 `_showStudyPlanRestructureDetails` 方法:底部弹窗展示详情标题、正文、提示卡片 - - 详情页使用 `CupertinoButton.filled` 确认按钮,毛玻璃风格容器 - -#### 修改文件 -| 文件 | 变更 | -|---|---| -| `features/tool_center/leisure/models/leisure_card.dart` | 价格枚举扩展为 6 值,`label`→`labelKey`,新增 `fromIdCompat` | -| `features/tool_center/leisure/presentation/widgets/leisure_bottom_filter.dart` | 接入 TLeisure 翻译,新增 9 筛选项 + 数量统计,修复 `allCards` bug | -| `features/tool_center/leisure/presentation/widgets/leisure_card_row.dart` | 筛选逻辑适配新价格键,兼容旧中文键 | -| `features/tool_center/leisure/presentation/widgets/leisure_card.dart` | `_buildPriceBadge` 使用 TLeisure,新增 `_priceLabel` | -| `features/tool_center/leisure/presentation/widgets/leisure_card_detail_sheet.dart` | 新增 `_priceLabel` 方法替代 `priceType.label` | -| `core/storage/database/app_database.dart` | 新增 `CorrectionRecords` 表 + V20 迁移 + 7 个 CRUD 方法 | -| `core/storage/database/app_database.g.dart` | 手动添加 `CorrectionRecords` 相关 drift 生成代码 | -| `features/correction/correction_provider.dart` | 重构:新增 `CorrectionItem` + 本地缓存逻辑 | -| `features/correction/presentation/correction_page.dart` | `_buildRecordItem` 改用 `CorrectionItem` 强类型 | -| `l10n/types/t_leisure.dart` | 新建:闲情逸致翻译类型(25 字段) | -| `l10n/types/t_study_plan.dart` | 新增 3 个重构详情翻译字段 | -| `l10n/types/t_root.dart` | 接入 `TLeisure` | -| `l10n/types/t.dart` | 导出 `t_leisure.dart` | -| `l10n/translation_io_service.dart` | 接入 `leisure` 节点导入导出 | -| `l10n/languages/*.dart` (14 个语言文件) | 补充 `leisure` 节点 + studyPlan 的 3 个新字段 | -| `features/discover/presentation/pages/home/discover_page.dart` | 弹窗新增"了解详情"按钮 + `_showStudyPlanRestructureDetails` 方法 | +#### 闲情逸致页面卸载后 ref 报错 +- **Issue: 闲情逸致页面报错 "Using ref when widget is about to or has been unmounted"**:`_scrollToToday` 在 `Future.delayed` 回调中使用 `ref.read()` 未检查 `mounted`,widget 卸载后 ref 不安全。 + - **修复**:`_scrollToToday` 和 `_scrollToDate` 异步回调前增加 `mounted` 检查 + - **文件**:lib/features/tool_center/leisure/presentation/pages/leisure_timeline_page.dart *** -## [v6.87.0] - 2026-06-17 +## [v6.94.2] - 2026-06-19 -### ✨ 多平台应用商店统一服务 + 多处交互优化 + 纠错页多语言 +### 🐛 修复 -#### 1. AppStore 地区不支持问题修复 -- **根因**: iOS端"我的→给个好评"直接拼接固定URL,未根据当前语言地区生成正确的App Store链接,导致部分用户看到"所在地区不支持"错误 -- **修复**: 新建 `AppStoreService` 统一应用商店服务,根据 `Locale.countryCode` 生成对应地区的App Store URL - - iOS: `https://apps.apple.com/{region}/app/id6771828376`(应用ID: 6771828376) - - Android: `market://details?id=apps.xy.xianyan` 或 Play Store 网页回退 - - 鸿蒙: 华为应用市场 - - Windows: `https://apps.microsoft.com/detail/9nqcv5gz10wnb?hl={lang}-{region}`(Product ID: 9nqcv5gz10wnb) - - macOS: 与iOS相同逻辑 -- **影响**: profile_page.dart、about_page.dart 的"给个好评"入口统一使用 `AppStoreService.getStoreUrlByLocale()` - -#### 2. 发现页下拉提示文案优化 -- **问题**: iOS端发现页下拉刷新时提示"松手打开",语义不准确 -- **修复**: 新增翻译键 `releaseToRefresh`,iOS端下拉提示改为"松手刷新",覆盖14种语言 - -#### 3. 学习计划重构预告弹窗 -- **需求**: 学习计划将在下个版本重构改名为"生活计划",需提前告知用户 -- **实现**: - - 发现页点击"学习计划"入口时弹出 `CupertinoAlertDialog` - - 内容: "学习计划 将在下个版本重构改名为 生活计划 部分功能有变更" - - 两个按钮: "确定" / "不再提醒" - - "不再提醒"使用 `KvStorage` 持久化标记 `app.study_plan.restructure_dont_remind`,下次不再弹出 - - 新增4个翻译键: `restructureTitle`/`restructureMessage`/`restructureConfirm`/`restructureDontRemind` - -#### 4. 管理学习计划页面无法返回修复 -- **问题**: 学习计划→管理学习计划页面无appbar,iOS端无法右滑返回 -- **根因**: 使用 `context.appGo()` 跳转,`go()` 会替换路由栈导致无法返回 -- **修复**: 改为 `context.appPush()` 使用push入栈,支持iOS右滑返回手势 - -#### 5. 闲情逸致"免费"标签改为"平价" -- **问题**: 闲情逸致食物标签显示"免费"二字不符合实际语义 -- **修复**: `LeisurePriceType.free` 的label从"免费"改为"平价",同步修改筛选底部弹窗和卡片行筛选逻辑 - -#### 6. 内容纠错页面全面多语言支持 -- **需求**: 纠错页面所有硬编码中文需支持多语言 -- **实现**: - - 新建 `t_correction.dart` 翻译类型,包含48个字段(页面标题、类型标签、状态、来源、验证码、记录等) - - 接入 `t_root.dart`、`t.dart`、`translation_io_service.dart` - - 14种语言文件全部补充 `correction` 节点翻译 - - `correction_page.dart` 所有硬编码中文替换为翻译调用: - - 类型选项/内容类型选项使用key映射 + `_contentTypeLabel`/`_correctionTypeLabel` 辅助方法 - - 提交校验、空内容、无效ID、提交成功/失败提示 - - 数学验证码弹窗(安全验证、提示、占位符、取消、确认、错误) - - 纠错记录弹窗(标题、空状态、联系邮箱反馈) - - 记录卡片(类型、内容类型、状态:待处理/已处理/已拒绝/未知、来源:本地/管理员) - - 修复记录卡片在长文本时的溢出问题(Text改用Flexible+ellipsis) - -#### 修改文件 -| 文件 | 变更 | -|---|---| -| `core/services/app_store_service.dart` | 新建:统一应用商店跳转服务,支持iOS/Android/鸿蒙/Windows/macOS多平台多地区 | -| `features/profile/presentation/profile_page.dart` | `_launchAppStore` 改用 `AppStoreService` | -| `features/profile/presentation/about_page.dart` | `_onRateApp` 增加 `WidgetRef` 参数,改用 `AppStoreService` | -| `features/discover/presentation/pages/home/discover_page.dart` | 下拉提示改用 `releaseToRefresh`;新增 `_showStudyPlanRestructureDialog` | -| `features/study_plan/presentation/study_plan_page.dart` | `appGo` 改为 `appPush` 修复返回导航 | -| `features/tool_center/leisure/models/leisure_card.dart` | `free` label 从"免费"改为"平价" | -| `features/tool_center/leisure/presentation/widgets/leisure_bottom_filter.dart` | 筛选项"免费"改为"平价" | -| `features/tool_center/leisure/presentation/widgets/leisure_card_row.dart` | 筛选逻辑"免费"改为"平价" | -| `features/correction/presentation/correction_page.dart` | 全页面硬编码中文替换为翻译调用 | -| `l10n/types/t_correction.dart` | 新建:纠错页翻译类型(48字段) | -| `l10n/types/t_root.dart` | 接入 `TCorrection` | -| `l10n/types/t.dart` | 导出 `t_correction.dart` | -| `l10n/types/t_discover_base.dart` | 新增 `releaseToRefresh` 字段 | -| `l10n/types/t_study_plan.dart` | 新增4个重构预告翻译字段 | -| `l10n/translation_io_service.dart` | 接入 `correction` 节点导入导出 | -| `l10n/languages/*.dart` (14个语言文件) | 补充 `releaseToRefresh`、4个重构预告字段、`correction` 节点完整翻译 | +#### 右栏返回栈行为修复(3处同源问题) +- **Issue1: 返回按钮行为不符合预期**:原 `_onRightPanelPop` 误用 `clear` 清空整个栈,导致已打开页面全部丢失,与"逐步返回上一页"预期不符。 + - **根因**:`_onRightPanelPop` 误用 `clear` 而非 `pop`,无论栈深多少都一次清空 + - **修复**:拆分为双按钮交互(左侧按钮组,栈深>1 时显示): + - 返回上一页(`CupertinoIcons.back` chevron 图标):调用 `pop` 逐页返回,符合 iOS `UINavigationController` 标准语义 + - 回仪表盘(`CupertinoIcons.house_fill` 房子图标):调用 `clear` 快捷回根,解决栈深较大时多次点击痛点 + - **文件**:lib/core/layout/workbench/workbench_layout.dart +- **Issue2: 快捷键返回行为错误**:`_RightPanelBackAction.invoke`(Ctrl/Cmd+←、Esc)同样误用 `clear`,与返回按钮同源问题。 + - **修复**:改为 `pop` 逐页返回,与 UI 返回按钮语义保持一致 + - **文件**:lib/app/layout/app_shell.dart +- **Issue3: 页面构建失败保留无效条目**:`pageWidget == null` 时仅返回默认面板,但无效条目残留在栈中,导致后续返回行为异常(脏栈)。 + - **修复**:构建失败时通过 `WidgetsBinding.instance.addPostFrameCallback` 延迟 `pop` 无效条目(避免 build-during-build 异常),再返回默认面板 + - **文件**:lib/core/layout/workbench/workbench_layout.dart *** -## [v6.86.0] - 2026-06-17 +## [v6.94.1] - 2026-06-19 -### 🐛 修复安卓桌面搜索快捷方式白屏问题 +### ✨ 交互增强与类型安全巡检(6项) -#### Bug: 在闲言其他页面点击桌面"搜索"快捷方式后白屏,只显示底栏,搜索框不弹出 -- **根因**: - 1. `QuickActionsService._resolveRoute('action_search')` 返回 `/profile?action=search`,`app.dart` 回调执行 `appRouter.push('/profile')` - 2. `/profile` 是 `StatefulShellRoute` 的 branch 路由(底栏 Tab),`push` 会在 shell 之外创建新的 ProfilePage 实例,导致白屏只显示底栏 - 3. `ProfilePage.pendingSearch` 是 static 变量,但 ProfilePage 的 `initState` 只在首次构建时触发。底栏使用 `indexedStack` 会预构建 ProfilePage,热启动时 `initState` 不会再触发,`pendingSearch` 标记永远不会被消费,搜索框不弹出 -- **修复**: - 1. `QuickActionsService._resolveRoute('action_search')` 改为返回特殊标记 `action:search`,不再返回带参数路由 - 2. `app.dart` 新增 `_handleSearchShortcut()` 方法:使用 `appRouter.go('/profile')` 切换到 profile Tab(而非 push),延迟 500ms 后直接在 root context 弹出 `SpotlightSearchOverlay` - 3. 兼容冷启动和热启动:冷启动时 go 切换 Tab 后直接弹搜索框;热启动时同样直接弹搜索框,不依赖 ProfilePage 的 initState - 4. 鸿蒙端:`OhosNavBridge.push` 切换页面后延迟 600ms 弹出搜索浮层 +#### 新功能 +- **富媒体新功能弹窗**:引导页"了解新功能"弹窗重构为图文卡片轮播(PageView),支持 emoji 解析+关键词图标匹配+光晕效果+动态主题色+手势滑动+页码指示器 +- **动态主题色图标组件**:新增 `ThemedIcon` 组件(shared/widgets/display/themed_icon.dart),支持 15 种颜色映射+圆形背景+光晕效果,随主题动态变化 +- **壁纸离线浏览**:壁纸画廊新增"💾 已缓存"离线模式,无网络环境下可浏览已加载壁纸,支持空状态提示+动态标题+自动退出离线模式 -#### 修改文件 -| 文件 | 变更 | -|---|---| -| `core/services/device/quick_actions_service.dart` | `_resolveRoute('action_search')` 返回 `action:search` 标记;更新文件头注释 | -| `app/app.dart` | 新增 `_handleSearchShortcut()` 方法;搜索快捷方式不再 push 路由,改用 go 切换 Tab + 直接弹 SpotlightSearchOverlay;移除未使用的 ProfilePage import,新增 SpotlightSearchOverlay import | +#### 功能完善 +- **稍后读批量管理持久化**:`toggleRead`/`markAllRead`/`markSelectedRead` 从仅内存更新升级为持久化到 DB(调用 `ChatMessageService.markIsRead`),采用乐观更新策略+`Future.wait` 并行执行 +- **Provider 生命周期修复**:修复 5 个非 autoDispose provider 重复进入页面不刷新的隐患: + - `chatMessagesProvider`:普通会话进入时主动 reload(原仅稍后读会话 reload) + - `chatAttachmentProvider`:进入聊天页时主动 `loadAttachments` + - `chatSessionProvider`:发现页 initState 主动 `refresh` + - `translateRecordListProvider`/`ttsRecordListProvider`:插件页 initState 主动 `refresh` + - `readLaterProvider`:稍后读页 initState 主动 `loadItems(refresh: true)` 兜底 + +#### 类型安全巡检(139处修复) +- **根因**:PHP/JSON 返回的数字可能被解析为 `int` 或 `double(num)`,直接 `as int` / `as int?` 会崩溃(`?? 0` 无法挽救,因为强转在 `??` 之前已抛异常) +- **修复模式**:统一使用 `SafeJson.parseInt()`(core/utils/safe_json.dart)替代 `as int` / `as int?` +- **P0 高风险修复(60处)**:task_core.dart(10)、daily_task_page.dart(2)、cloud_cache_record.dart(5)、cloud_cache_service.dart(1)、signaling_service.dart(2)、input_action.dart(2)、transfer_task.dart(1)、anonymous_submit_page.dart(2)、interaction_provider.dart(1)、offline_manager.dart(1)、spotlight_search_provider.dart(1)、chat_session_provider.dart(2)、weather_settings_page.dart(1)、poetry_settings_page.dart(1)、theme_sections_preview.dart(3)、image_cache_models.dart(2)、pomodoro_core.dart(1)、image_cache_metadata_service.dart(1)、permission_service.dart(1)、kv_storage.dart(1)、notification_center.dart(6)、solar_term_core.dart(12) +- **P1 中风险修复(79处)**:user_center_models.dart(4)、user_stats_provider.dart(9)、user_center_service.dart(7)、favorite_repository.dart(4)、correction_provider.dart(4)、ctc_api_client.dart(2)、ctc_note_provider.dart(4)、interaction_provider.dart(1)、coin_provider.dart(1)、public_profile_page.dart(1)、learning_progress_page.dart(3)、learning_center_page.dart(7)、statistics_page.dart(14)、leisure_card.dart(2)、wallpaper_health_service.dart(2)、settings_change_logger.dart(1)、cache_config.dart(5)、weather_provider.dart(2)、appbar_date_display.dart(1)、qrcode_scanner_page.dart(1)、user_model.dart(4) + +#### 涉及文件 +| 模块 | 文件 | 变更类型 | +|------|------|----------| +| 新功能弹窗 | new_features_dialog.dart | 重构为图文卡片轮播 | +| 主题色图标 | themed_icon.dart(新) | 新增动态主题色图标组件 | +| 壁纸离线 | wallpaper_gallery_view.dart, wallpaper_source_bar.dart | 离线模式+缓存chip | +| 稍后读持久化 | readlater_provider.dart | 批量已读持久化到DB | +| Provider生命周期 | chat_flow_page.dart, discover_page.dart, translate_plugin_page.dart, tts_plugin_page.dart, readlater_page.dart | initState主动reload | +| 类型安全P0 | 22个文件 | as int → SafeJson.parseInt() | +| 类型安全P1 | 21个文件 | as int? → SafeJson.parseInt() | *** -## [v6.85.0] - 2026-06-17 +## [v6.94.0] - 2026-06-19 -### ✨ 新增Token过期智能续期提示功能 +### 🔧 批量Bug修复与功能增强(16项) -- **TokenRefreshWatcher**: 监听网络恢复事件,自动尝试Token续期,续期失败则弹窗引导重新登录 - - 监听 `ConnectivityService.onTypeChange`,当从离线恢复到在线时自动触发Token检查 - - 调用 `TokenService.checkToken()` 检测Token有效性,无效时自动调用 `TokenService.refreshToken()` 续期 - - 续期失败弹出Cupertino风格弹窗,提供"稍后"和"重新登录"两个选项 - - 使用 `_dialogShowing` + `KvStorage` 双重标记防止重复弹窗 - - 在 `PostAgreementInitializer` 中 `ConnectivityService.init()` 之后启动,确保依赖就绪 - -#### 修改文件 -| 文件 | 变更 | -|---|---| -| `core/services/auth/token_refresh_watcher.dart` | 新建Token续期监听器服务 | -| `core/services/post_agreement_initializer.dart` | 在ConnectivityService初始化后启动TokenRefreshWatcher | - -*** - -## [v6.84.0] - 2026-06-17 - -### 🐛 修复鸿蒙端6个核心Bug:认证失败 + 保存相册 + 粘贴不工作 - -#### Bug1-4: 鸿蒙端所有需要认证的接口返回401未授权 -- **根因**: `SecureStorage._useSharedPreferences` 仅判断 macOS/Windows,鸿蒙端使用 `flutter_secure_storage` 但其原生 MethodChannel 未实现,导致 Token 无法读写。所有需要认证的接口(修改密码、密保问题、注销账号、删除设备)均返回401 -- **修复**: `_useSharedPreferences` 增加 `pu.isOhos` 判断,鸿蒙端降级使用 `SharedPreferences` 存储 Token;`deleteAll()` 补充 `user_id` 键名匹配 - -#### Bug5: 鸿蒙端保存到相册报错 -- **根因**: 多处代码直接调用 `Gal.putImageBytes`/`Gal.putVideo`,但鸿蒙端 gal 插件不支持,调用即崩溃 -- **修复**: 所有 `Gal` 调用点增加鸿蒙端判断,使用 `OhosCompatibilityHelper.saveImageToGalleryCompat`/`saveVideoToGalleryCompat` 通过系统分享降级 -- **影响文件**: `share_sheet.dart`、`leisure_share_sheet.dart`、`progress_share_card.dart`、`china_colors_page.dart`、`chat_video_bubble.dart` - -#### Bug6: 鸿蒙端任意输入框粘贴无反应 -- **根因**: Flutter 标准 TextInputPlugin 长按粘贴使用 `flutter/platform` 通道的 `Clipboard.getData` 方法,鸿蒙端 Flutter 引擎 C++ 层未实现此方法,导致粘贴操作静默失败 -- **修复**: 在原生端 `EntryAbility.ets` 注册 `flutter/platform` 通道的剪贴板方法拦截器,将 `Clipboard.getData`/`setData`/`hasStrings` 路由到鸿蒙原生 pasteboard API;非剪贴板方法调用 `result.notImplemented()` 交由引擎默认处理;Dart 端 `ClipboardBridge` 新增 `installOhosClipboardInterceptor()` 和 `setData()` 方法 - -#### 修改文件 -| 文件 | 变更 | -|---|---| -| `core/storage/secure_storage.dart` | `_useSharedPreferences` 增加 `pu.isOhos` 判断;`deleteAll()` 补充 `user_id` 键名 | -| `core/utils/platform/clipboard_bridge.dart` | 新增 `installOhosClipboardInterceptor()` 和 `setData()` 方法 | -| `core/utils/platform/ohos_compatibility_helper.dart` | 新增 `saveVideoToGalleryCompat()` 视频保存兼容方法 | -| `shared/widgets/feedback/share_sheet.dart` | `_saveToGallery()` 鸿蒙端使用 `OhosCompatibilityHelper` 降级 | -| `features/tool_center/leisure/.../leisure_share_sheet.dart` | `_saveToAlbum()` 鸿蒙端使用 `OhosCompatibilityHelper` 降级 | -| `features/progress/.../progress_share_card.dart` | 保存相册鸿蒙端使用 `OhosCompatibilityHelper` 降级 | -| `features/discover/.../china_colors_page.dart` | 保存色卡鸿蒙端使用 `OhosCompatibilityHelper` 降级 | -| `features/discover/.../chat_video_bubble.dart` | 保存视频鸿蒙端使用 `OhosCompatibilityHelper` 降级 | -| `main.dart` | 新增 `ClipboardBridge.installOhosClipboardInterceptor()` 调用 | -| `ohos/.../EntryAbility.ets` | 注册 `flutter/platform` 通道剪贴板方法拦截器;新增 `Clipboard.setData` 方法;新增平台通道剪贴板方法(返回Flutter期望格式) | - -*** - -## [v6.83.0] - 2026-06-17 - -### 🐛 修复收藏功能两个核心Bug:主页收藏后收藏页不显示 + 取消收藏后数据残留 - -#### Bug1: 主页句子卡片收藏后,我的收藏页面不显示 -- **根因1**: `getFavoriteCount()` 从 `translateFavorites` 表计数,而非 `sentences` 表的 `isFavorite=true`,导致收藏页面 `_totalFavCount` 为0,显示空状态而非本地收藏列表 -- **根因2**: `HomeSentence.fromDb` 将 `row.isFavorite` 错误赋值给 `isLiked` 而非 `isFavorited`,导致从DB加载的句子收藏状态丢失 -- **修复**: 新增 `getSentenceFavoriteCount()` 从sentences表统计;修复 `fromDb` 字段映射 `isLiked: row.isLiked, isFavorited: row.isFavorite` - -#### Bug2: 我的收藏取消收藏后数据仍在(本地DB和UI未同步更新) -- **根因**: `setFavoriteFlag(targetId.toString(), false)` 传入纯数字ID(如"123"),但sentences表存复合ID(如"feed_123"),导致UPDATE匹配0行 -- **修复**: 新增 `setFavoriteFlagForTarget(targetType, targetId, value)` 方法,同时尝试复合ID和纯数字ID两种格式;3处调用点全部替换 - -#### 增强: 收藏页面取消收藏后主页句子状态同步 -- **问题**: 收藏页面取消收藏后,主页句子卡片的收藏图标不更新 -- **修复**: `FavoriteNotifier.toggleFavorite` 成功后调用 `notifyFavoriteRefresh()`;`HomeNotifier` 监听 `favoriteRefreshStream`,收到事件后从DB批量同步句子 `isFavorited` 状态 - -#### 修改文件 -| 文件 | 变更 | -|---|---| -| `core/storage/database/app_database.dart` | 新增 `setFavoriteFlagForTarget()` 兼容复合ID和数字ID;新增 `getSentenceFavoriteCount()` 从sentences表统计收藏数 | -| `features/home/favorite_repository.dart` | `toggleFavorite()` 中 `setFavoriteFlag` → `setFavoriteFlagForTarget` | -| `features/home/presentation/favorite/favorite_actions_mixin.dart` | `removeFavorite()` 和 `deleteSelected()` 中 `setFavoriteFlag` → `setFavoriteFlagForTarget`;`loadLocalStats()` 改用 `getSentenceFavoriteCount()` | -| `features/home/providers/home_sentence_model.dart` | `fromDb` 修复字段映射:`isLiked: row.isLiked, isFavorited: row.isFavorite`(原 `isLiked: row.isFavorite`) | -| `features/home/providers/favorite_provider.dart` | `toggleFavorite()` 成功后调用 `notifyFavoriteRefresh()` 通知主页 | -| `features/home/providers/home_provider.dart` | 新增 `_favoriteRefreshSub` 监听收藏刷新事件;新增 `_syncFavoriteStateFromDb()` 批量同步句子收藏状态 | - -*** - -## [v6.82.0] - 2026-06-17 - -### 🐛 修复拾光栏配置开启后不实时更新 + 节气日历与实际日期不一致 +#### 新功能 +- **引导页新功能开关**:引导页第三页(个性化设置)新增"了解 V{版本号} 新功能"开关(默认关闭),开启后进入主页弹出当前版本更新日志弹窗,支持动态主题和14种多语言 +- **闲情逸致emoji统一替换**:将闲情逸致页面(含设置页、聊天气泡、输入面板)约55处emoji替换为CupertinoIcons,保留社交互动(❤️点赞、⭐收藏)和个性化按钮的emoji #### Bug修复 -- **拾光栏配置不实时更新**:`_DisplayItemsSheet` 从 `StatelessWidget` 改为 `ConsumerWidget`,内部直接 `ref.watch(dateDisplayProvider)` 监听配置变化,确保在子Sheet中切换开关后UI立即同步更新(之前子Sheet通过构造函数接收config快照,Provider更新后子Sheet无法感知) -- **节气日期表排序错误**:2025/2026年节气数据中小寒(1月)、大寒(1月)排在列表末尾,导致 `getCurrentTerm()` 遍历时1月日期覆盖6月日期,6月17日错误返回大寒而非芒种。已将数据按年内时间顺序重排(小寒→大寒→立春→…→冬至) -- **2026年雨水日期修正**:2月18日 → 2月19日 -- **getCurrentTerm跨年边界**:年初(1月1-4日)未到小寒时,取上一年最后一个节气(冬至),避免返回null +- **Beta页面问卷按钮**:增加右上角可关闭小角标,点击后临时隐藏问卷入口 +- **Windows分发渠道文案**:软件信息页平台兼容卡片点击Windows的toast改为"由Microsoft Store分发"(14种语言同步) +- **稍后读不显示**:修复主页卡片详情添加稍后读后,发现-稍后读页面不显示的问题(ChatFlowPage每次进入主动reload + notifyReadlaterRefresh确保执行) +- **情景诗词无响应**:修复输入框发送内容页面无变化、收藏与分享按钮无响应(接入诗词刷新+本地DB收藏+ShareSheet分享) +- **日签卡片换一句无响应**:添加isRefreshing加载态、错误反馈、空内容检查,接入频道句子切换 +- **使用报告数据为空**:修复阅读趋势和活跃热力图数据解析(传year参数、多键名兼容、字段名兼容、List包装) +- **阅读目标无变化**:重写reading_goal_provider,从本地DB获取今日进度(viewsToday/favoritesToday),添加连续天数更新逻辑 +- **灵感朗读音频无关**:修复TTS朗读音频与实际内容无关(_mySpokenText文本匹配 + 先stop再speak) +- **壁纸筛选不一致**:添加supportsCategory getter区分源是否支持分类筛选,客户端二次过滤确保结果一致 +- **取消收藏数据仍在**:增强setFavoriteFlagForTarget第三种匹配策略(feedType+ID后缀),mergeWithLocalDb改为追加尾部 +- **密保问题设置失败**:修复_parseSecQuestion类型安全(int vs num),添加_toSafeInt安全转换,refreshUser错误处理增强 -#### 修改文件 -| 文件 | 变更 | -|---|---| -| `features/solar_term/solar_term_core.dart` | 节气日期表按时间顺序重排(小寒大寒移至年初);修正2026雨水日期(18→19);`getCurrentTerm()` 增加跨年边界处理 | -| `features/home/presentation/date_config_sheet.dart` | `_DisplayItemsSheet` 改为 `ConsumerWidget`,内部watch Provider实时更新;`_showDisplayItemsSheet()` 简化参数 | +#### 涉及文件 +| 模块 | 文件 | 变更类型 | +|------|------|----------| +| Beta页面 | experimental_features_page.dart | 添加关闭角标 | +| 引导页 | onboarding_provider.dart, personalization_page.dart, t_onboarding.dart | 新功能开关 | +| 引导页弹窗 | new_features_dialog.dart(新), home_page.dart, kv_storage.dart | 弹窗+触发逻辑 | +| 闲情逸致 | 21个chat相关文件 | emoji→CupertinoIcons | +| 稍后读 | chat_flow_page.dart, home_interaction_mixin.dart | reload+通知修复 | +| 情景诗词 | poetry_page.dart | 发送/收藏/分享实现 | +| 日签卡片 | daily_card_provider.dart, daily_card_page.dart | 加载态+错误反馈 | +| 使用报告 | reading_report_core.dart | 数据解析修复 | +| 阅读目标 | reading_goal_provider.dart | 完全重写 | +| 灵感TTS | inspiration_detail_sheet.dart | 文本匹配修复 | +| 壁纸 | template_models.dart, wallpaper_gallery_view.dart | 分类筛选修复 | +| 收藏 | app_database.dart, favorite_repository.dart | 匹配策略增强 | +| 密保 | user_model.dart, security_question_page.dart | 类型安全修复 | +| 多语言 | 14个语言文件 | Windows文案+knowNewFeatures | *** -## [v6.81.0] - 2026-06-17 +## [v6.93.1] - 2026-06-19 -### 📱 软件信息页面 — 设备类型卡片增加系统版本号显示 +### 🔧 MethodChannel 命名风格统一 -#### 功能描述 -- 在软件信息页面的「设备信息」卡片中,设备类型后增加括号显示系统版本号 -- 例如:桌面端 (macOS 15.0)、移动端 (Android 14)、移动端 (iOS 18.0)、桌面端 (Windows 10 (23H2))、移动端 (鸿蒙 5.0)、桌面端 (Linux 6.1) -- Web 端不显示系统版本(保持原有行为) +将所有自定义 MethodChannel/EventChannel 名称统一为 `apps.xy.xianyan/{feature}` 风格,与应用包名 `apps.xy.xianyan` 保持一致。 -#### 修改文件 -| 文件 | 变更 | -|---|---| -| `core/services/device/device_info_service.dart` | 新增 `getSystemVersion()` 异步方法,支持 Android/iOS/macOS/Windows/Linux/鸿蒙各平台系统版本获取;新增 `_extractOhosVersion()` 和 `_extractKernelVersion()` 辅助方法 | -| `features/profile/presentation/app_info_sections.dart` | `DeviceInfoSection` 新增 `_systemVersion` 状态和 `_loadSystemVersion()` 方法;设备类型 `GridInfoItem` 的 value 改为 `$deviceType ($_systemVersion)` 格式 | +#### 变更清单 + +| 旧名称 | 新名称 | 涉及文件 | +|--------|--------|---------| +| `com.xianyan.macos` | `apps.xy.xianyan/macos` | macos_platform_service.dart + MainFlutterWindow.swift | +| `com.xianyan.macos.app` | `apps.xy.xianyan/macos.app` | macos_platform_service.dart + AppDelegate.swift | +| `com.xianyan.windows` | `apps.xy.xianyan/windows` | windows_platform_service.dart + flutter_window.cpp | +| `com.xianyan.clipboard` | `apps.xy.xianyan/clipboard` | clipboard_bridge.dart | +| `xianyan/usb_transport` | `apps.xy.xianyan/usb_transport` | usb_transport_service.dart | +| `xianyan/usb_events` | `apps.xy.xianyan/usb_events` | usb_discovery_service.dart | +| `com.xianyan.touchbar.*` | `apps.xy.xianyan.touchbar.*` | MainFlutterWindow.swift (NSTouchBar 标识符) | + +#### 不变项 +- Android 通道 `apps.xy.xianyan/{feature}` — 已是正确风格 +- 鸿蒙通道 `plugins.flutter.io/{plugin}_ohos` — 模拟 Flutter 标准插件,保持不变 + +#### 影响 +- **功能无影响**:MethodChannel 名称只需 Dart 端与原生端一致即可 +- **可维护性提升**:消除 4 种命名风格混用,统一为 `apps.xy.xianyan/{feature}` *** -## [v6.80.0] - 2026-06-17 +## [v6.93.0] - 2026-06-19 -### ✨ 登录页新增「记住账户」功能 +### 🖥️ 桌面端原生功能扩展(第二批) -#### 功能描述 -- 登录页面密码登录模式下,新增「记住账户」复选框,勾选后登录成功会保存账户名 -- 下次打开登录页时自动填充已保存的账户名并勾选「记住账户」 -- 取消勾选后登录成功会清除已保存的账户信息 +#### 剪贴板富文本(HTML)支持 +- `lib/core/utils/platform/clipboard_bridge.dart` 新增 `setRichText({text, html})` / `getHtml()` / `hasHtml()` 三个方法 +- 桌面端通过 `com.xianyan.clipboard` MethodChannel 调用原生 NSPasteboard/CF_HTML +- 移动端/Web 降级为纯文本(`_htmlToPlainText` 工具方法去除标签+反转义实体) +- 鸿蒙端仅支持纯文本,HTML 读写返回 null/false +- 保持隐私协议守卫(读操作需同意协议) + +#### 侧边栏折叠记忆 +- `SplitViewState` 新增 `navBarCollapsed` (bool) / `navBarWidth` (double) / `tabSplitRatios` (Map) 三个字段 +- `SplitViewNotifier` 新增 `setNavBarCollapsed` / `toggleNavBarCollapsed` / `setNavBarWidth` / `saveCurrentTabSplitRatio` / `setCurrentTabWithMemory` 方法 +- `adaptive_nav_bar.dart` 垂直导航栏支持折叠态(48px 仅图标)/ 展开态(72px 图标+文字),底部新增折叠按钮 +- 折叠状态持久化到 KvStorage(`nav_bar_collapsed` / `workbench_nav_bar_width`) + +#### 分屏记忆 +- 每个 Tab 独立分屏比例,切换 Tab 时自动保存当前比例并恢复目标 Tab 比例 +- `tabSplitRatios` 以 JSON 序列化存储到 `tab_split_ratios` key +- `setCurrentTabWithMemory(index)` 方法实现保存+恢复逻辑 + +#### 可拖拽导航栏宽度 +- `workbench_layout.dart` 的 `_navBarWidth()` 改为从 `splitViewProvider` 读取动态宽度 +- 折叠态 48px / 展开态使用持久化的 `navBarWidth`(默认 72px,范围 48~240px) +- `_assembleLayout` 垂直导航栏 SizedBox 改用动态宽度 + +#### Windows Mica Alt 特效 +- `windows_acrylic_service.dart` 新增 `_isMicaAltSupported()` 方法,检测 Win11 build >= 22621 +- `applyEffect` 新增 Mica Alt 分支(优先级:Mica Alt → Mica → Acrylic,自动降级) +- 使用 `WindowEffect.tabbed` 作为 Mica Alt 等价实现(flutter_acrylic 1.1.4 的最接近选项) +- `effectName` getter 新增 `windows_mica_alt` 标识 +- buildNumber 检测结果缓存,避免重复读取 + +#### Spec 文档 +- 新增 `docs/desktop_native_expansion_spec.md`(14 项功能实施方案,开发完成后删除) + +*** + +## [v6.92.0] - 2026-06-19 + +### 🍎 macOS 原生功能扩展(5 项) + +#### Touch Bar 支持(NSTouchBar) +- `MainFlutterWindow.swift` 新增 `setTouchBarItems` 方法,创建 NSTouchBar + NSButton 项 +- 按钮点击通过 `channel.invokeMethod("touchBarAction", action)` 回调 Dart +- 仅带 Touch Bar 的 MacBook 显示,无 Touch Bar 设备自动忽略 +- `MainFlutterWindow` 实现 `NSTouchBarDelegate`,按索引创建按钮 + +#### NSSharingService 共享面板 +- `MainFlutterWindow.swift` 新增 `showShareSheet` 方法,弹出 NSSharingServicePicker +- 支持 text/url/imageBytes 三种内容类型组合 +- 图片通过 `FlutterStandardTypedData` → `NSImage` 转换 + +#### NSDockTile 徽章 +- `AppDelegate.swift` 新增 `setDockBadge` 方法,设置 Dock 图标数字徽章 +- count <= 0 时清除徽章 + +#### NSStatusItem 菜单栏金句 +- `AppDelegate.swift` 新增 `updateStatusBarSentence` 方法,菜单栏显示金句 +- 超过 30 字符截断显示,前缀 💬 emoji +- 点击金句复制完整内容到剪贴板(`currentSentence` 属性存储完整内容) +- `statusItem` 强引用持有,防止被释放 + +#### CoreSpotlight 索引 +- `AppDelegate.swift` 新增 `indexSpotlightItems` / `clearSpotlightIndex` 方法 +- 使用 `CSSearchableIndex.default()` 索引条目(id/title/content/type) +- 沙盒内可直接使用,无需额外 entitlements +- 异步操作,不阻塞 UI + +#### 应用级通道架构 +- 新增 `com.xianyan.macos.app` MethodChannel(AppDelegate 注册) +- `applicationShouldTerminateAfterLastWindowClosed` 改为 `false`,支持托盘常驻 +- `MacosPlatformService` 新增 `_appChannel` 常量 + 6 个静态方法 + +*** + +## [v6.91.2] - 2026-06-19 + +### 🔗 深度链接服务完善 — xianyan:// scheme 跳转 +- `lib/core/services/network/deep_link_service.dart` 新增 `_preResolve` 预解析方法,处理需要特殊路由映射的 scheme: + - `xianyan://note/{id}` → `/notes/edit?id={id}`(笔记编辑页用 query param,无法通过配置驱动解析器的子路径匹配处理) + - `xianyan://note` → `/notes`(笔记列表页) + - `xianyan://sentence/{id}` → `/home`(句子详情 Sheet 需 HomeSentence 对象,暂导航到首页) +- `lib/core/router/route_registry.dart` 稍后阅读路由新增 `xianyan://readlater` 别名 +- `macos/Runner/Info.plist` 新增 `CFBundleURLTypes` 注册 `xianyan` URL Scheme(此前 macOS 端未注册) +- `ios/Runner/Info.plist` 在现有 `CFBundleURLSchemes` 数组中追加 `xianyan` scheme(此前仅注册 ShareExtension) +- Android 端 `AndroidManifest.xml` 已有 `xianyan` scheme intent-filter,无需修改 +- `DeepLinkService.init()` 已在 `main.dart:236` 调用,无需重复初始化 + +*** + +## [v6.91.1] - 2026-06-19 + +### 📝 笔记编辑页 — 桌面端拖拽文件接入 +- 笔记编辑页(`lib/features/note/presentation/note_edit_page.dart`)接入 `desktop_drop`,桌面端(`pu.isDesktop`)支持拖拽文件到笔记 +- 拖拽视觉反馈:半透明遮罩 + 虚线边框(`_DashedBorderPainter`)+ "拖放文件到笔记"提示,`AnimatedContainer` 200ms 过渡,`Positioned.fill` + `IgnorePointer` 不阻挡正常操作 +- 文件处理逻辑(`_handleDrop`): + - 图片(.png/.jpg/.jpeg/.gif/.webp):读取为 base64,插入 Markdown 图片语法 `![文件名](data:image/png;base64,...)` + - 文本(.txt/.md):读取内容追加到笔记末尾 + - 其他文件:插入 Markdown 链接 `[文件名](file:///路径)` +- 错误处理:读取失败时 `AppToast.showError` 提示 +- 移动端不包裹 `DropTarget`,不影响现有自动保存/Markdown 预览功能 + +*** + +## [v6.91.0] - 2026-06-18 + +### 📦 依赖同步修复 +- `pubspec.ohos.yaml` 补齐 `tray_manager`/`macos_window_utils`/`flutter_acrylic` 三库声明 +- 原因:Dart 编译时静态解析 import 链(`app.dart` → `desktop_service_registry.dart` → 实现文件 → 三库),鸿蒙端虽 no-op 但必须声明,否则报 `Target of URI doesn't exist` +- `iOS_macOS_Developer_Guide.md` §5.5 新增特殊案例说明 + §5.6 新增常见问题条目 + +### 🖥️ 桌面端原生增强(macOS/Windows) + +#### 系统托盘(tray_manager) +- 跨平台系统托盘服务:图标 + Tooltip + 未读角标 + 右键菜单 +- 托盘右键菜单 4 组分隔线分组:主操作/快速访问(最近阅读子菜单)/模式切换/系统 +- 未读角标:合并稍后阅读 + 每日拾句未读数,macOS setTitle 数字显示 +- 每日拾句未读概念:DailySentenceViewedService 本地存储已查看 id 列表 +- 托盘事件处理:单击切换窗口可见性、双击聚焦、右键弹出菜单 +- 托盘图标:自绘 SVG + Pillow 生成 PNG(浅色/深色两套,16x16/32x32) +- 多语言支持:TrayMenuLabels(zhCN/enUS) + +#### macOS 原生菜单栏(PlatformMenuBar) +- MacosMenuBarWrapper:6 个顶级菜单(闲言/文件/编辑/视图/窗口/帮助) +- 业务功能入口:新建笔记/灵感、打开稍后阅读、偏好设置、每日拾句 +- 模式切换:深色模式、工作台模式(带勾选状态) +- 系统标准项:关于/隐藏/退出/全屏/最小化/缩放(PlatformProvidedMenuItem) + +#### 自定义窗口标题栏(软件样式) +- DesktopWindowTitleBar:替代系统默认标题栏,支持动态主题+动态样式 +- macOS 风格:左侧红黄绿三圆点按钮(hover 显示图标) +- Windows 风格:右侧最小化/最大化/关闭方按钮(hover 背景变化) +- 毛玻璃效果(BackdropFilter)、双击最大化/还原、拖拽移动窗口 +- DesktopTitleBarStyle:动态样式配置(高度/透明度/模糊/按钮大小等) +- main.dart:titleBarStyle: TitleBarStyle.hidden 隐藏系统标题栏 +- macOS 原生:titlebarAppearsTransparent=true + 隐藏系统红黄绿按钮 + +#### Windows 原生侧能力补齐(6 个 MethodChannel 方法) +- setWindowTitle:SetWindowTextW 设置窗口标题 +- setFullscreen/isFullscreen:保存/恢复窗口 RECT+样式,全屏切换 +- setMinSize:WM_GETMINMAXINFO 处理最小尺寸限制(DPI 缩放) +- performHapticFeedback:MessageBeep 模拟触觉反馈(4 种类型) +- getSystemAppearance:读取注册表 AppsUseLightTheme + +#### 窗口特效(毛玻璃/亚克力) +- macOS:MacosWindowEffectService 标题栏融合 + 侧边栏毛玻璃(NSVisualEffectView sidebar 材质) +- Windows 11:WindowsAcrylicService Mica 背景 +- Windows 10:Acrylic 效果(深色 0xCC1F1F1F / 浅色 0xCCF3F3F3) +- Win11 版本检测:解析 operatingSystemVersion(build>=22000) +- 主题切换时同步窗口特效(build + didChangePlatformBrightness) + +#### 动态主题 +- 窗口特效随主题变化:DesktopWindowEffectService.applyEffect(isDark) +- 托盘图标随主题变化:macOS isTemplate 自动反色,Windows 明暗两套图标 +- 标题栏随主题变化:深色/浅色/AMOLED 三种背景色 + +#### 依赖变更 +- 新增:tray_manager ^0.5.3 / macos_window_utils ^1.9.1 / flutter_acrylic ^1.1.4 +- 移除:nearby_connections ^4.1.1(三处模板同步:pubspec.yaml/pubspec.ohos.yaml/pubspec.macos.yaml) + +#### 新增文件 +| 文件 | 作用 | +|---|---| +| `lib/core/services/desktop/desktop_tray_service.dart` | 托盘服务抽象 + TrayMenuItem 模型 | +| `lib/core/services/desktop/desktop_window_effect_service.dart` | 窗口特效服务抽象 | +| `lib/core/services/desktop/desktop_service_registry.dart` | 服务注册表(按平台注入实现) | +| `lib/core/services/desktop/desktop_tray_menu_builder.dart` | 托盘菜单构建器(4 组分隔线分组) | +| `lib/core/services/desktop/daily_sentence_viewed_service.dart` | 每日拾句已查看服务 | +| `lib/core/services/desktop/implementations/tray_manager_tray_service.dart` | tray_manager 托盘服务实现 | +| `lib/core/services/desktop/implementations/macos_window_effect_service.dart` | macOS 窗口特效实现 | +| `lib/core/services/desktop/implementations/windows_acrylic_service.dart` | Windows Acrylic/Mica 实现 | +| `lib/features/desktop/desktop_tray_controller.dart` | 托盘控制器(整合服务+菜单+未读数) | +| `lib/features/desktop/macos_menu_bar_wrapper.dart` | macOS 原生菜单栏包装器 | +| `lib/features/desktop/desktop_window_title_bar.dart` | 自定义窗口标题栏 Widget | +| `lib/features/home/presentation/providers/readlater/tray_unread_count_provider.dart` | 托盘未读数 Provider | +| `assets/svgs/tray_icon.svg` | 托盘图标 SVG 源文件 | +| `scripts/generate_tray_icons.py` | 托盘图标 PNG 生成脚本 | #### 修改文件 | 文件 | 变更 | |---|---| -| `l10n/types/t_auth.dart` | 新增 `rememberAccount` 翻译键字段 | -| `l10n/languages/zh_cn.dart` | 新增中文翻译:记住账户 | -| `l10n/languages/en.dart` | 新增英文翻译:Remember Account | -| `features/auth/presentation/login_form_sections.dart` | `PasswordFormSection` 新增 `isRemembered`/`onToggleRemember` 参数,将忘记密码按钮改为 Row 布局(左:记住账户复选框,右:忘记密码) | -| `features/auth/presentation/login_page.dart` | 新增 `_rememberAccount` 状态、`_saveRememberAccount()` 方法;`_loadLastLoginAccount()` 读取 `remember_account` 偏好;`_handlePasswordLogin()` 登录成功后保存/清除账户 | +| `lib/main.dart` | 新增 titleBarStyle: TitleBarStyle.hidden | +| `lib/app/app.dart` | 初始化桌面服务、窗口特效、托盘控制器、MacosMenuBarWrapper、主题同步 | +| `lib/app/layout/app_shell.dart` | 集成 DesktopWindowTitleBar | +| `lib/core/services/device/windows_platform_service.dart` | 新增 6 个 MethodChannel 方法 | +| `macos/Runner/MainFlutterWindow.swift` | titlebarAppearsTransparent=true + 隐藏系统按钮 | +| `windows/runner/win32_window.h` | 新增 6 个方法声明 + 全屏状态成员变量 | +| `windows/runner/win32_window.cpp` | 实现 6 个方法 + WM_GETMINMAXINFO | +| `windows/runner/flutter_window.cpp` | 扩展 MethodChannel 处理 6 个方法 | +| `pubspec.yaml/pubspec.ohos.yaml/pubspec.macos.yaml` | 新增 3 库 + 移除 nearby_connections | + +#### 工作台设置页(P0-8) +- 新增独立页面 `WorkbenchSettingsPage`:整合 4 项迁移设置 + 7 项交互/视觉增强 +- 迁移项:工作台模式开关、分屏开关、导航栏位置、分屏比例 +- 新增交互增强(持久化):专注阅读模式、右栏分屏、拖拽出窗(占位)、右栏标签页(占位)、中栏拖拽排序 +- 新增视觉增强(持久化):工作台毛玻璃背景、空状态动画 +- `SplitViewState` 扩展 7 个 freezed 字段 + 对应 setter(KvStorage 持久化) +- 通用设置"显示"组:移除 4 项迁移项,新增"工作台模式"导航入口 +- 路由注册:`AppRoutes.workbenchSettings` + `route_registry.dart` + deepLink `xianyan://settings/workbench` + +| 文件 | 变更 | +|---|---| +| `lib/features/settings/presentation/workbench/workbench_settings_page.dart` | 新增工作台设置页(4 组设置 + 私有组件) | +| `lib/core/router/app_routes.dart` | 新增 `workbenchSettings` 路由常量 | +| `lib/core/router/route_registry.dart` | 注册 workbench-settings 路由 | +| `lib/core/providers/split_view_provider.dart` | 扩展 7 个交互增强字段 + setter | +| `lib/core/providers/split_view_provider.freezed.dart` | 同步 freezed 生成代码 | +| `lib/features/settings/presentation/general/general_settings_sections.dart` | 移除 4 项迁移项 + 新增导航项 | +| `lib/features/settings/presentation/general/general_settings_page.dart` | `_onNavigate` 新增 workbench_settings case | + +*** + +> v6.90.1 及更早版本(v6.87.0 ~ v6.90.1)已归档至软件特性功能文档。 +> 主要特性概览: +> - **v6.90.1**: 工作台模式多语言支持 + 中栏宽度 KvStorage 持久化 +> - **v6.90.0**: PC工作台布局重构(微信PC式三栏 WorkbenchLayout + RightPanelStackNotifier + 路由拦截) +> - **v6.89.1**: macOS MissingPluginException 修复 + Expanded 布局错误修复 +> - **v6.89.0**: 评分弹窗商店名称多语言 + Beta问卷按钮隐藏跨平台修复 +> - **v6.88.0**: 闲情逸致价格档位扩展(6档)+ 纠错历史本地缓存(drift)+ 学习计划详情页 + 闲情逸致全模块多语言 +> - **v6.87.0**: 多平台应用商店统一服务(AppStoreService)+ 学习计划重构预告 + 闲情逸致"免费"改"平价" + 纠错页全面多语言 diff --git a/Scripts/generate_tray_icons.py b/Scripts/generate_tray_icons.py new file mode 100644 index 00000000..4dd996cd --- /dev/null +++ b/Scripts/generate_tray_icons.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +""" +闲言APP — 系统托盘图标生成脚本 +创建时间: 2026-06-18 +作用: 用 Pillow 生成 macOS/Windows 托盘图标 PNG(浅色+深色两套) +设计: 对话气泡 + 三条横线,体现"记录言语"主题 +输出: + - assets/images/tray_icon_light.png (黑色图标,用于 macOS template + Win 浅色主题) + - assets/images/tray_icon_dark.png (白色图标,用于 Win 深色主题) + - assets/images/tray_icon_light_32.png (32x32 高清版) + - assets/images/tray_icon_dark_32.png (32x32 高清版) +""" + +import os +from PIL import Image, ImageDraw + +# ============================================================ +# 配置 +# ============================================================ +OUTPUT_DIR = os.path.join(os.path.dirname(__file__), '..', 'assets', 'images') +SIZES = [16, 32] # 生成 16x16 和 32x32 两种尺寸 + +# 颜色定义 +COLOR_LIGHT = (0, 0, 0, 255) # 黑色(浅色主题用) +COLOR_DARK = (255, 255, 255, 255) # 白色(深色主题用) +COLOR_TRANSPARENT = (0, 0, 0, 0) + + +def draw_tray_icon(size: int, color: tuple) -> Image.Image: + """绘制托盘图标 + + 设计: + - 对话气泡(圆角矩形)+ 三条横线 + 气泡尾巴 + - 单色设计,简洁现代 + """ + # 创建透明背景 + img = Image.new('RGBA', (size, size), COLOR_TRANSPARENT) + draw = ImageDraw.Draw(img) + + # 缩放因子(基于 16x16 设计) + scale = size / 16.0 + + def s(v): + """缩放坐标""" + return int(v * scale) + + # ============================================================ + # 1. 对话气泡主体(圆角矩形) + # ============================================================ + # 气泡位置:左上 (1, 1) 到右下 (15, 11) + bubble_x1, bubble_y1 = s(1), s(1) + bubble_x2, bubble_y2 = s(15), s(11) + bubble_radius = s(2) + + # 绘制圆角矩形气泡 + draw.rounded_rectangle( + [bubble_x1, bubble_y1, bubble_x2, bubble_y2], + radius=bubble_radius, + fill=color, + ) + + # ============================================================ + # 2. 气泡尾巴(左下角,指向说话者) + # ============================================================ + tail_points = [ + (s(3), s(11)), # 尾巴起点(气泡底部) + (s(3), s(14)), # 尾巴尖 + (s(6), s(11)), # 尾巴终点(气泡底部) + ] + draw.polygon(tail_points, fill=color) + + # ============================================================ + # 3. 气泡内三条横线(代表文字) + # ============================================================ + line_color = COLOR_TRANSPARENT # 用透明色"挖出"横线 + line_thickness = max(1, s(1)) + + # 三条横线的位置(在气泡内部居中) + line_x1 = s(3) + line_x2_short = s(10) # 短线结束 + line_x2_long = s(13) # 长线结束 + + # 第一条横线(短) + draw.rectangle( + [line_x1, s(4), line_x2_short, s(4) + line_thickness - 1], + fill=line_color, + ) + # 第二条横线(长) + draw.rectangle( + [line_x1, s(6), line_x2_long, s(6) + line_thickness - 1], + fill=line_color, + ) + # 第三条横线(中) + draw.rectangle( + [line_x1, s(8), s(11), s(8) + line_thickness - 1], + fill=line_color, + ) + + return img + + +def main(): + """主函数:生成所有图标""" + os.makedirs(OUTPUT_DIR, exist_ok=True) + + for size in SIZES: + # 浅色版(黑色图标) + light_img = draw_tray_icon(size, COLOR_LIGHT) + light_name = f'tray_icon_light{"" if size == 16 else f"_{size}"}.png' + light_path = os.path.join(OUTPUT_DIR, light_name) + light_img.save(light_path, 'PNG') + print(f'✅ 生成: {light_path} ({size}x{size})') + + # 深色版(白色图标) + dark_img = draw_tray_icon(size, COLOR_DARK) + dark_name = f'tray_icon_dark{"" if size == 16 else f"_{size}"}.png' + dark_path = os.path.join(OUTPUT_DIR, dark_name) + dark_img.save(dark_path, 'PNG') + print(f'✅ 生成: {dark_path} ({size}x{size})') + + print('\n🎉 所有托盘图标生成完成!') + + +if __name__ == '__main__': + main() diff --git a/assets/images/tray_icon_dark.png b/assets/images/tray_icon_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..5fe64866df6d102e146fd66dedea4c7671f1d416 GIT binary patch literal 143 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`VV*9IAr*6y6BY;<9Fq7C1pb@S z1QHa2jd=_V12`fs%x&>EG@EYpXwrgC?g@-%g4|O0Vq-qYpEWwh*zcwm#LS=VmD0eJ ovR)utBTRq7Yo#ux$vF%RN;Vq8XTv&pfHp9Ay85}Sb4q9e0DdVe$p8QV literal 0 HcmV?d00001 diff --git a/assets/images/tray_icon_dark_32.png b/assets/images/tray_icon_dark_32.png new file mode 100644 index 0000000000000000000000000000000000000000..f0de7c193e0ef09ab5eeca21e04b49e7741b4de6 GIT binary patch literal 188 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJW=|K#kcv5Prv-8yP~dUC{NV5U zJAd;G+__g5UjDw)DQUJ-Z zIqU#?>!csaY(}A~N4DHMdahybghmfhPD9?+=?znh|97??S$2z4S?yk)sByn`k<-$O m8xBuf0yO`fcwt)0EXvM#$HbOlt3)Z#ISihzelF{r5}E*%qe=4s literal 0 HcmV?d00001 diff --git a/assets/images/tray_icon_light.png b/assets/images/tray_icon_light.png new file mode 100644 index 0000000000000000000000000000000000000000..88b060c71c2a95b11add4a3598164455df61b508 GIT binary patch literal 134 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`zMd|QAr*6y6BY;ddGY*- zOlK1g9C1)gIQUpKhvD$jo*gO~Pnl9a)E|iXAV0h9KeJe`PlleNwW^T=HhP*dFsxMLl03Us;yKV@22WQ%mvv4FO#q%^EN1`! literal 0 HcmV?d00001 diff --git a/assets/images/tray_icon_light_32.png b/assets/images/tray_icon_light_32.png new file mode 100644 index 0000000000000000000000000000000000000000..af493dbb825da1dc610be0637db5591edbeeed7a GIT binary patch literal 183 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJI!_nJkcv5Pr=8_Hpuo|*@Z;b3 znRm->@$0QAJ@GIH7*}za>Z{79g&c<5~)fW^cle$=%5*f@g zd6xvYc4mrKuV&lx;NOoDrh^6yZ64PeCT~41;Zd_?ra|(eFRL$9?%U(HK;*sQhp2W3 h%{umKpZFOqyxlu_ZGVe>7X-S5!PC{xWt~$(696))Nh<&V literal 0 HcmV?d00001 diff --git a/assets/svgs/tray_icon.svg b/assets/svgs/tray_icon.svg new file mode 100644 index 00000000..6752e16b --- /dev/null +++ b/assets/svgs/tray_icon.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/design-preview/pc-workspace-layout.html b/docs/design-preview/pc-workspace-layout.html new file mode 100644 index 00000000..80eb53fd --- /dev/null +++ b/docs/design-preview/pc-workspace-layout.html @@ -0,0 +1,1111 @@ + + + + + +闲言 PC 工作台布局原型 — 微信PC式三栏 + + + + + + + + +
+ + 三栏模式 · ≥1280px +
+ + +
+ + + + + + + + +
+ + +
+
+ + 概览 +
+ + + +
+
+
+ +
+
+
+ + + + + diff --git a/docs/desktop_native_enhancement_spec.md b/docs/desktop_native_enhancement_spec.md new file mode 100644 index 00000000..eafa2b2a --- /dev/null +++ b/docs/desktop_native_enhancement_spec.md @@ -0,0 +1,949 @@ +# 桌面端原生增强开发文档 + +> 创建时间: 2026-06-18 +> 状态: 待评审 +> 类型: spec 文档(开发完成后删除) +> 关联文档: [iOS\_macOS\_Developer\_Guide.md](../iOS_macOS_Developer_Guide.md) | [pc-workbench-spec.md](./pc-workbench-spec.md) + +*** + +## 一、功能描述 + +为 macOS / Windows / Pad 桌面端补齐原生集成能力,使闲言 APP 在桌面端具备与原生应用一致的体验: + +1. **系统托盘**:最小化到托盘、托盘图标、右键 Pop 菜单、未读角标 +2. **原生菜单栏定制**:macOS 顶部菜单栏显示"闲言"及业务功能入口;Windows 通过 Flutter 自绘 Pop 菜单 +3. **Windows 原生侧能力补齐**:补齐与 macOS 对等的 MethodChannel 方法 +4. **窗口特效**:macOS 侧边栏毛玻璃 + 标题栏融合;Windows 10 Acrylic + Windows 11 Mica +5. **工作台模式页面**:整合通用设置中的分屏/工作台相关项为独立页面,并扩展交互增强功能 +6. **清理冗余依赖**:移除已声明废弃的 `nearby_connections` + +所有更改支持**动态主题**(深色/浅色/跟随系统)和**动态样式**(主题色/字体/圆角等设计令牌实时响应)。 + +*** + +## 二、目标与非目标 + +### 2.1 目标 + +| # | 目标 | 平台 | 库 | +| - | ------------------------------------ | --------------- | --------------------------- | +| 1 | 系统托盘(图标/Tooltip/右键菜单/未读角标/最小化到托盘) | macOS/Win/Linux | `tray_manager ^0.5.3` | +| 2 | macOS 原生菜单栏业务入口(PlatformMenuBar) | macOS | Flutter 内置 | +| 3 | Windows 原生侧 MethodChannel 补齐 6 个方法 | Windows | 自建 | +| 4 | macOS 侧边栏毛玻璃 + 标题栏融合 | macOS 12+ | `macos_window_utils ^1.9.1` | +| 5 | Windows 10 Acrylic + Windows 11 Mica | Win10/Win11 | `flutter_acrylic ^1.1.4` | +| 6 | 工作台模式独立设置页面 | 全平台 | 无新增 | +| 7 | 移除 nearby\_connections | 全平台 | 无 | + +### 2.2 非目标(本次不实施) + +* ❌ 全局快捷键(hotkey\_manager)—— 留待后续迭代 + +* ❌ 多窗口(desktop\_multi\_window)—— 留待后续迭代 + +* ❌ Linux 原生侧 MethodChannel —— 项目无 linux/ 目录,暂不实施 + +* ❌ Windows 原生 Win32 Menu 资源 —— 采用纯 Flutter 自绘 Pop 菜单方案 + +* ❌ 鸿蒙端托盘/菜单 —— 鸿蒙端无桌面环境,不实施 + +*** + +## 三、依赖变更 + +### 3.1 新增依赖(仅 pubspec.macos.yaml) + +```yaml +dependencies: + # --- 桌面端原生增强 --- + tray_manager: ^0.5.3 # 跨平台系统托盘(macOS/Win/Linux) + macos_window_utils: ^1.9.1 # macOS NSWindow 级精细控制 + flutter_acrylic: ^1.1.4 # 窗口特效(macOS模糊/Win Acrylic/Mica) +``` + +### 3.2 移除依赖(三处模板同步) + +```yaml +# 移除以下行(文档 §2.8.7 已声明废弃) +nearby_connections: ^4.1.1 +``` + +涉及文件: + +* [pubspec.yaml](../pubspec.yaml) L251 + +* [pubspec.ohos.yaml](../pubspec.ohos.yaml) L290 + +* [pubspec.macos.yaml](../pubspec.macos.yaml) L251 + +### 3.3 鸿蒙端处理 + +| 新增库 | pubspec.ohos.yaml | 原因 | +| -------------------- | ----------------- | ---------- | +| `tray_manager` | ❌ 不添加 | 鸿蒙端无桌面环境 | +| `macos_window_utils` | ❌ 不添加 | 仅 macOS 平台 | +| `flutter_acrylic` | ❌ 不添加 | 仅桌面端 | + +鸿蒙端通过 `pu.isDesktop` 守卫,桌面服务代码不会执行;若 import 路径在鸿蒙端编译报错,使用条件导入或 stub 实现。 + +### 3.4 dependency\_overrides 预判 + +| 库 | 依赖 | 冲突风险 | 处理 | +| --------------------------- | ------------------------------ | ------------------------- | ------------------------------------------------------------------------------ | +| `tray_manager ^0.5.3` | `screen_retriever`、`menu_base` | 低 | 无 | +| `macos_window_utils ^1.9.1` | 纯 macOS Swift | 无 | 无 | +| `flutter_acrylic ^1.1.4` | `window_manager` | 已有 window\_manager ^0.5.1 | 兼容,需在 main.dart 中先 `Window.initialize()` 再 `windowManager.ensureInitialized()` | + +*** + +## 四、系统托盘设计 + +### 4.1 托盘图标资源 + +| 主题 | 资源路径 | 说明 | +| ---- | ----------------------------------- | --------------------------------------------------------------- | +| 浅色主题 | `assets/images/tray_icon_light.png` | 深色图标(用于浅色菜单栏) | +| 深色主题 | `assets/images/tray_icon_dark.png` | 浅色图标(用于深色菜单栏) | +| 未读角标 | 程序化绘制 | tray\_manager 支持 `setTitle` 显示数字角标(macOS)/ `setImage` 叠加角标(Win) | + +> 图标尺寸:macOS 22×22 pt(@2x 44px),Windows 16×16 px(@2x 32px)。提供多分辨率 PNG。 + +### 4.2 托盘右键 Pop 菜单(分隔线分组) + +``` +┌─────────────────────────────┐ +│ 显示主窗口 Enter │ ← 主操作组 +│ 新建笔记 Cmd+N │ +├─────────────────────────────┤ ← 分隔线 +│ 每日拾句 │ ← 快速访问组 +│ 稍后阅读 │ +│ 最近阅读 ▸ │ ← 子菜单(5 条稍后阅读记录) +│ ├─ <标题1> │ +│ ├─ <标题2> │ +│ └─ ... │ +├─────────────────────────────┤ ← 分隔线 +│ 切换深色模式 │ ← 模式切换组 +│ 静默模式(暂停通知) ✓ │ +├─────────────────────────────┤ ← 分隔线 +│ 偏好设置 Cmd+, │ ← 系统组 +│ 检查更新 │ +│ 退出闲言 Cmd+Q │ +└─────────────────────────────┘ +``` + +### 4.3 托盘行为 + +| 事件 | 行为 | +| ------ | ------------------------------------------- | +| 单击(左键) | 显示/隐藏主窗口(若已显示则最小化到托盘) | +| 双击(左键) | 显示主窗口并聚焦 | +| 右键 | 弹出 Pop 菜单 | +| 未读数变化 | 更新角标(macOS `setTitle` 数字;Windows 程序化叠加角标图层) | + +### 4.4 未读角标数据源 + +| 角标类型 | Provider | 字段 | +| ------ | ---------------------------- | ------------------ | +| 每日拾句未读 | `dailySentenceProvider` | `unreadCount` | +| 稍后阅读未读 | `readLaterProvider` | `unreadCount` | +| 合计 | 新建 `trayUnreadCountProvider` | 监听上述两个 Provider 求和 | + +未读数为 0 时隐藏角标。 + +### 4.5 接口设计 + +```dart +// lib/core/services/desktop/desktop_tray_service.dart + +/// 桌面端系统托盘服务抽象 +abstract class DesktopTrayService { + /// 初始化托盘(图标、Tooltip、菜单) + Future init(); + + /// 销毁托盘 + Future destroy(); + + /// 更新托盘图标(根据主题) + Future setIcon({required bool isDark}); + + /// 更新 Tooltip + Future setToolTip(String tip); + + /// 更新未读角标(0 表示隐藏) + Future setUnreadBadge(int count); + + /// 更新右键菜单 + Future setMenu(List items); + + /// 托盘事件流(单击/双击/右键) + Stream get events; + + /// 是否支持托盘(平台判断) + bool get isSupported; +} + +/// 托盘菜单项 +class TrayMenuItem { + final String label; + final VoidCallback? onTap; + final List? submenu; + final bool isSeparator; + final bool checked; + final String? shortcut; + + const TrayMenuItem({ + this.label, + this.onTap, + this.submenu, + this.isSeparator = false, + this.checked = false, + this.shortcut, + }); + + const TrayMenuItem.separator() : this(isSeparator: true); +} +``` + +### 4.6 实现策略 + +* **macOS / Windows / Linux**:基于 `tray_manager` 实现 `TrayManagerTrayService` + +* **iOS / Android / 鸿蒙**:返回 `_StubDesktopTrayService`(所有方法 no-op,`isSupported = false`) + +* **服务注册**:在 `lib/core/services/desktop/desktop_service_registry.dart` 中按 `pu.isDesktop` 注入 + +*** + +## 五、原生菜单栏定制 + +### 5.1 macOS 原生菜单栏(PlatformMenuBar) + +#### 5.1.1 菜单结构 + +``` +闲言 +├── 关于闲言 +├── 偏好设置… Cmd+, +├── ───────── +├── 新建笔记 Cmd+N +├── 新建灵感 Cmd+Shift+N +├── 打开文件… Cmd+O +├── ───────── +├── 隐藏闲言 Cmd+H +├── 隐藏其他 Cmd+Alt+H +├── 显示全部 +├── ───────── +└── 退出闲言 Cmd+Q + +编辑(系统标准:撤销/重做/剪切/复制/粘贴/查找/拼写) + +视图 +├── 进入全屏 Ctrl+Cmd+F +├── 切换侧边栏 Cmd+Alt+S +├── 切换深色模式 Cmd+Shift+D +├── 工作台模式 Cmd+Shift+W +└── 专注阅读模式 Cmd+Shift+Z + +窗口(系统标准:最小化/缩放/全部前置) + +帮助 +├── 闲言帮助 +├── 检查更新 +└── 反馈问题 +``` + +#### 5.1.2 实现方式 + +使用 Flutter 内置 `PlatformMenuBar` + `PlatformMenu` + `PlatformMenuItem` API(纯 Dart,无需改 xib): + +```dart +// lib/core/services/desktop/macos_menu_bar_service.dart + +class MacosMenuBarService { + static void register(BuildContext context, WidgetRef ref) { + if (!pu.isMacOS) return; + + PlatformMenuBar( + menus: [ + _buildAppMenu(context, ref), + _buildEditMenu(), + _buildViewMenu(context, ref), + _buildWindowMenu(), + _buildHelpMenu(context, ref), + ], + ); + } + + static PlatformMenu _buildAppMenu(BuildContext context, WidgetRef ref) { + return PlatformMenu( + label: '闲言', + menus: [ + PlatformMenuItem( + label: '关于闲言', + onSelected: () => _showAboutDialog(context), + ), + PlatformMenuItem( + label: '偏好设置…', + shortcut: const SingleActivator(LogicalKeyboardKey.comma, meta: true), + onSelected: () => context.appPush(AppRoutes.settings), + ), + // ... 新建笔记 / 新建灵感 / 打开文件 + PlatformMenuItem(label: '', onSelected: null), // 分隔线 + // ... 隐藏/退出 + ], + ); + } +} +``` + +#### 5.1.3 接入点 + +在 [app.dart](../lib/app/app.dart) 的 `MaterialApp.router` builder 中,根据 `pu.isMacOS` 包裹 `PlatformMenuBar`: + +```dart +builder: (context, child) { + final wrapped = _wrapWithResponsive(child); + if (pu.isMacOS) { + return PlatformMenuBar( + menus: MacosMenuBarService.buildMenus(context, ref), + child: wrapped, + ); + } + return wrapped; +} +``` + +### 5.2 Windows Pop 菜单(纯 Flutter 自绘) + +#### 5.2.1 方案 + +Windows 不使用原生 Win32 Menu 资源,改用 Flutter 自绘 Pop 菜单,与托盘右键菜单复用同一套组件: + +```dart +// lib/shared/widgets/desktop/desktop_pop_menu.dart + +class DesktopPopMenu extends StatelessWidget { + final List groups; + + /// 使用 Cupertino 风格 + 毛玻璃背景,与 iOS26 风格一致 + /// 基于 showMenu + 自绘 DesktopMenuPane +} +``` + +#### 5.2.2 触发方式 + +| 触发点 | 方式 | +| ------- | ----------------------------------------- | +| 托盘右键 | `tray_manager` 菜单(跨平台原生菜单) | +| 标题栏菜单按钮 | Flutter 自绘 `DesktopPopMenu`(Cupertino 风格) | +| 右键菜单 | Flutter 自绘 `DesktopPopMenu` | + +#### 5.2.3 视觉规范 + +* 背景:`CupertinoColors.systemBackground` + `BackdropFilter` 模糊(iOS26 风格) + +* 圆角:12px(与设计令牌 `--radius-lg` 对齐) + +* 分组分隔:1px 分隔线(`CupertinoColors.separator`) + +* 选中态:`CupertinoColors.systemBlue` 勾选标记 + +* 快捷键提示:右侧灰色文字 + +*** + +## 六、Windows 原生侧能力补齐 + +### 6.1 现状 + +[flutter\_window.cpp](../windows/runner/flutter_window.cpp) 的 `com.xianyan.windows` MethodChannel 仅处理 `setDarkMode` 1 个方法。 + +### 6.2 补齐方法清单 + +| 方法 | 功能 | Win32 API | +| ----------------------- | ------------- | ------------------------------------------------------------------------------------------- | +| `setWindowTitle` | 设置窗口标题 | `SetWindowTextW(hwnd, title)` | +| `setFullscreen` | 进入/退出全屏 | 调整窗口样式 `WS_POPUP` + 保存/恢复原 RECT | +| `isFullscreen` | 查询全屏状态 | 检查当前窗口样式 | +| `setMinSize` | 设置窗口最小尺寸 | 在 `WM_GETMINMAXINFO` 中处理 | +| `performHapticFeedback` | 触觉反馈(桌面端无触觉) | `MessageBeep(MB_OK)` 蜂鸣反馈 | +| `getSystemAppearance` | 获取系统外观(深色/浅色) | 读取注册表 `HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme` | + +### 6.3 实现要点 + +#### 6.3.1 全屏切换 + +```cpp +// windows/runner/win32_window.cpp 新增 +bool Win32Window::SetFullscreen(bool fullscreen) { + if (fullscreen) { + // 保存原窗口 RECT 和样式 + GetWindowRect(hwnd_, &saved_rect_); + saved_style_ = GetWindowLong(hwnd_, GWL_STYLE); + // 设置为无边框全屏 + SetWindowLong(hwnd_, GWL_STYLE, WS_POPUP | WS_VISIBLE); + MONITORINFO mi = { sizeof(mi) }; + GetMonitorInfo(MonitorFromWindow(hwnd_, MONITOR_DEFAULTTONEAREST), &mi); + SetWindowPos(hwnd_, HWND_TOP, mi.rcMonitor.left, mi.rcMonitor.top, + mi.rcMonitor.right - mi.rcMonitor.left, + mi.rcMonitor.bottom - mi.rcMonitor.top, SWP_NOZORDER); + } else { + // 恢复原样式和位置 + SetWindowLong(hwnd_, GWL_STYLE, saved_style_); + SetWindowPos(hwnd_, nullptr, saved_rect_.left, saved_rect_.top, + saved_rect_.right - saved_rect_.left, + saved_rect_.bottom - saved_rect_.top, SWP_NOZORDER); + } + is_fullscreen_ = fullscreen; + return true; +} +``` + +#### 6.3.2 最小尺寸 + +在 `WindowProc` 的 `WM_GETMINMAXINFO` 中处理: + +```cpp +case WM_GETMINMAXINFO: { + if (min_width_ > 0 && min_height_ > 0) { + MINMAXINFO* mmi = (MINMAXINFO*)lparam; + mmi->ptMinTrackSize.x = min_width_; + mmi->ptMinTrackSize.y = min_height_; + } + return 0; +} +``` + +#### 6.3.3 系统外观读取 + +```cpp +// 读取注册表判断深色/浅色 +bool IsSystemDarkMode() { + HKEY hKey; + DWORD value = 0; + DWORD size = sizeof(value); + if (RegOpenKeyEx(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, KEY_READ, &hKey) == ERROR_SUCCESS) { + RegQueryValueEx(hKey, L"AppsUseLightTheme", nullptr, nullptr, + (LPBYTE)&value, &size); + RegCloseKey(hKey); + } + return value == 0; // 0 = 深色, 1 = 浅色 +} +``` + +### 6.4 Dart 侧封装 + +扩展 [windows\_platform\_service.dart](../lib/core/services/device/windows_platform_service.dart): + +```dart +class WindowsPlatformService { + static const _channel = MethodChannel('com.xianyan.windows'); + + static Future setWindowTitle(String title) => + _channel.invokeMethod('setWindowTitle', {'title': title}); + + static Future setFullscreen(bool fullscreen) => + _channel.invokeMethod('setFullscreen', {'fullscreen': fullscreen}); + + static Future isFullscreen() async => + await _channel.invokeMethod('isFullscreen') ?? false; + + static Future setMinSize(int width, int height) => + _channel.invokeMethod('setMinSize', {'width': width, 'height': height}); + + static Future performHapticFeedback() => + _channel.invokeMethod('performHapticFeedback'); + + static Future isSystemDarkMode() async => + await _channel.invokeMethod('getSystemAppearance') == 'dark'; + + // 已有 + static Future syncTheme(bool isDark) => + _channel.invokeMethod('setDarkMode', {'isDark': isDark}); +} +``` + +*** + +## 七、窗口特效 + +### 7.1 macOS 窗口特效(macos\_window\_utils ^1.9.1) + +#### 7.1.1 最低支持版本 + +**macOS 12 Monterey**(NSVisualEffectViewMaterial.sidebar 完整支持) + +#### 7.1.2 特效范围 + +| 特效 | 实现 | 应用区域 |
| +| ------ | ----------------------------------------------------------------- | ------------------------ | :--------- | +| 侧边栏毛玻璃 | `NSVisualEffectView` + `material = .sidebar` | 工作台导航栏 + 中栏背景 |
| +| 标题栏融合 | `titlebarAppearsTransparent = true` + `titleVisibility = .hidden` | 顶部标题栏 |
| +| 工具栏样式 | `MacosWindowUtilsToolbarStyle` | 顶部工具栏(如有) |
| +| 内容延伸 | \`styleMask | = .fullSizeContentView\` | 内容延伸到标题栏区域 | + +#### 7.1.3 实现方式 + +替换当前自建 [MainFlutterWindow.swift](../macos/Runner/MainFlutterWindow.swift) 中的部分逻辑,改用 `macos_window_utils` 的 Dart API: + +```dart +// lib/core/services/desktop/macos_window_service.dart + +class MacosWindowService { + static Future applyEffect({ + required bool isDark, + required bool sidebarBlur, + }) async { + if (!pu.isMacOS) return; + + // 标题栏融合 + await Window.setTitleBarVisibility(style: TitleBarVisibility.hidden); + await Window.enableFullSizeContentView(); + + // 侧边栏毛玻璃 + if (sidebarBlur) { + await MacosNSVisualEffectViewContainer( + material: NSVisualEffectViewMaterial.sidebar, + blendingMode: NSVisualEffectViewBlendingMode.behindWindow, + ).apply(); + } + } +} +``` + +#### 7.1.4 与现有 MacosPlatformService 的关系 + +| 现有方法 | 处理 | +| -------------------------------- | ---------------------------------- | +| `setTitleBarTransparent` | 迁移到 `macos_window_utils`(保留旧方法做兼容) | +| `setTitleBarStyle` | 迁移到 `macos_window_utils` | +| `setToolbarVisible` | 迁移到 `macos_window_utils` | +| `syncTheme` | 保留(macos\_window\_utils 不负责主题同步) | +| `setWindowTitle` | 保留 | +| `setFullscreen` / `isFullscreen` | 保留 | +| `setMinSize` | 保留 | +| `performHapticFeedback` | 保留 | +| `getSystemAppearance` | 保留 | + +### 7.2 Windows 窗口特效(flutter\_acrylic ^1.1.4) + +#### 7.2.1 特效策略 + +| Windows 版本 | 特效 | API | +| --------------- | --------------- | ------------------------------------------------ | +| Win11 22000+ | Mica(云母,跟随系统主题) | `Window.setEffect(effect: WindowEffect.mica)` | +| Win10 1809+ | Acrylic(亚克力半透明) | `Window.setEffect(effect: WindowEffect.acrylic)` | +| Win10 早期 / Win7 | 无特效(纯色背景) | 降级处理 | + +#### 7.2.2 版本检测 + +```dart +// lib/core/services/desktop/windows_acrylic_service.dart + +class WindowsAcrylicService { + static Future applyEffect({required bool isDark}) async { + if (!pu.isWindows) return; + + final isWin11 = await _isWindows11OrLater(); + if (isWin11) { + await Window.setEffect( + effect: WindowEffect.mica, + dark: isDark, + ); + } else { + await Window.setEffect( + effect: WindowEffect.acrylic, + gradientColor: isDark + ? const Color(0xCC000000) + : const Color(0xCCFFFFFF), + ); + } + } + + static Future _isWindows11OrLater() async { + // 通过 Platform.operatingSystemVersion 解析 + // Win11 build >= 22000 + final version = Platform.operatingSystemVersion; + // 解析逻辑... + return false; + } +} +``` + +#### 7.2.3 初始化顺序 + +在 [main.dart](../lib/main.dart) 中,桌面端初始化顺序: + +```dart +if (pu.isDesktop) { + // 1. flutter_acrylic 初始化(必须在 window_manager 之前) + await Window.initialize(); + + // 2. window_manager 初始化 + await windowManager.ensureInitialized(); + + // 3. 应用窗口特效 + if (pu.isMacOS) { + await MacosWindowService.applyEffect(isDark: ..., sidebarBlur: true); + } else if (pu.isWindows) { + await WindowsAcrylicService.applyEffect(isDark: ...); + } + + // 4. 显示窗口 + await windowManager.waitUntilReadyToShow(...); + await windowManager.show(); +} +``` + +### 7.3 动态主题响应 + +所有窗口特效需监听主题变化,实时切换: + +```dart +// 在 app.dart 的主题切换回调中 +ref.listen(themeProvider, (prev, next) { + final isDark = next.brightness == Brightness.dark; + if (pu.isMacOS) { + MacosWindowService.applyEffect(isDark: isDark, sidebarBlur: true); + } else if (pu.isWindows) { + WindowsAcrylicService.applyEffect(isDark: isDark); + } + // 同步托盘图标主题 + DesktopTrayService.instance.setIcon(isDark: isDark); +}); +``` + +*** + +## 八、工作台模式页面 + +### 8.1 目标 + +将通用设置"显示"组中的 4 个分屏/工作台相关项整合为独立页面,并扩展交互增强功能。 + +### 8.2 现状 + +[general\_settings\_page.dart](../lib/features/settings/presentation/general/general_settings_page.dart) "显示"组包含: + +* `nav_bar_position`(导航栏位置) + +* `split_view_ratio`(分屏比例) + +* `split_view_enabled`(分屏功能开关) + +* `workbench_enabled`(工作台模式开关) + +### 8.3 新页面设计 + +#### 8.3.1 页面位置 + +`lib/features/settings/presentation/workbench/workbench_settings_page.dart` + +#### 8.3.2 页面结构 + +``` +工作台模式设置 +├── 顶部说明卡片(工作台模式介绍 + 当前状态) +│ +├── 一、布局模式(单选) +│ ○ 工作台三栏(推荐)—— 导航+列表+详情 +│ ○ 双栏分屏 —— 左右双栏+可拖拽分割线 +│ ○ 单栏全屏 —— 传统移动端布局 +│ +├── 二、导航栏(仅工作台/双栏模式生效) +│ ├── 导航栏位置(左/右/顶/底 4 选 1) +│ └── 导航栏图标大小(小/中/大) +│ +├── 三、分栏调整 +│ ├── 分屏比例(30:70 ~ 60:40,7 档) +│ ├── 中栏宽度(320-600px,滑块) +│ └── 分割线粗细(1/2/3px) +│ +├── 四、交互增强(新增) +│ ├── 专注阅读模式(开关)—— 隐藏中栏+导航栏,右栏全宽沉浸 +│ ├── 右栏分屏(开关)—— 超宽屏(≥1920px)时右栏再分屏 +│ ├── 拖拽出窗(开关)—— 右栏页面可拖拽成独立窗口(需多窗口支持,本次仅开关占位) +│ ├── 右栏标签页(开关)—— 右栏支持多 Tab(本次仅开关占位) +│ └── 中栏拖拽排序(开关)—— 中栏列表项长按拖拽排序 +│ +├── 五、快捷键 +│ ├── 查看/自定义快捷键(导航到快捷键设置页) +│ └── 快捷键列表预览(Ctrl+1/2/3 切 Tab 等) +│ +└── 六、视觉增强 + ├── 工作台毛玻璃背景(开关,仅桌面端) + ├── 右栏入场动画(滑入/淡入/无) + └── 空状态动画(开关) +``` + +### 8.4 状态扩展 + +扩展 [split\_view\_provider.dart](../lib/core/providers/split_view_provider.dart): + +```dart +@freezed +class SplitViewState with _$SplitViewState { + const factory SplitViewState({ + // === 现有字段 === + @Default(0.4) double splitRatio, + @Default(NavBarPosition.left) NavBarPosition navBarPosition, + @Default(false) bool splitViewEnabled, + @Default(true) bool workbenchEnabled, + @Default(0) int currentTab, + // ... 现有字段保留 + + // === 新增字段(交互增强)=== + @Default(false) bool focusReadingMode, // 专注阅读模式 + @Default(false) bool rightPanelSplit, // 右栏分屏 + @Default(false) bool popOutWindow, // 拖拽出窗(占位) + @Default(false) bool rightPanelTabs, // 右栏标签页(占位) + @Default(false) bool middlePanelDragSort, // 中栏拖拽排序 + @Default(true) bool workbenchBlurBackground, // 工作台毛玻璃 + @Default(PageTransitionMode.slide) PageTransitionMode rightPanelTransition, + @Default(true) bool emptyStateAnimation, + }) = _SplitViewState; +} +``` + +### 8.5 通用设置页面调整 + +从 [general\_settings\_sections.dart](../lib/features/settings/presentation/general/general_settings_sections.dart) "显示"组中移除以下 4 项,改为一个导航入口: + +```dart +// 显示组新增导航项 +SettingItem( + id: 'workbench_settings', + title: t.settings.workbenchSettings, + subtitle: t.settings.workbenchSettingsSubtitle, + icon: CupertinoIcons.rectangle_split_3x1, + color: CupertinoColors.systemIndigo, + type: SettingType.navigation, + route: AppRoutes.workbenchSettings, // 新增路由 +), + +// 移除以下 4 项(迁移到工作台设置页) +// - nav_bar_position +// - split_view_ratio +// - split_view_enabled +// - workbench_enabled +``` + +### 8.6 路由注册 + +在 [app\_router.dart](../lib/core/router/app_router.dart) 和 [ohos\_nav\_bridge.dart](../lib/core/router/ohos_nav_bridge.dart) 双写: + +```dart +// app_router.dart +GoRoute( + path: AppRoutes.workbenchSettings, + name: 'workbench-settings', + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (context, state) => + iosSlideTransition(state: state, child: const WorkbenchSettingsPage()), +), + +// ohos_nav_bridge.dart +AppRoutes.workbenchSettings: (_) => const WorkbenchSettingsPage(), +``` + +### 8.7 可扩展交互增强功能分析 + +基于现有三方库能力,工作台模式可扩展的功能(本次实施标注 ✅,留待后续标注 ⏳): + +| # | 功能 | 依赖库 | 状态 | 说明 | +| -- | -------- | ---------------------------------------- | ---- | ----------------------------- | +| 1 | 专注阅读模式 | 无(复用 splitViewProvider) | ✅ 本次 | 隐藏中栏+导航栏,右栏全宽沉浸 | +| 2 | 右栏分屏(四栏) | 无(复用 thirdPanelContent 字段) | ✅ 本次 | 超宽屏 ≥1920px 时右栏再分屏 | +| 3 | 中栏拖拽排序 | `flutter_slidable` + 自研 | ✅ 本次 | 中栏列表项长按拖拽 | +| 4 | 工作台毛玻璃 | `macos_window_utils` + `flutter_acrylic` | ✅ 本次 | 桌面端侧边栏模糊 | +| 5 | 右栏入场动画 | `flutter_animate` | ✅ 本次 | Tab 切换/右栏页面切换动画 | +| 6 | 空状态动画 | `rive` / `lottie` | ✅ 本次 | OverviewDashboard 空状态 | +| 7 | 拖拽出窗 | `desktop_multi_window` | ⏳ 后续 | 需多窗口支持 | +| 8 | 右栏标签页 | 自研 | ⏳ 后续 | 复用 RightPanelStackNotifier 扩展 | +| 9 | 全局快捷键 | `hotkey_manager` | ⏳ 后续 | Cmd+Shift+N 等 | +| 10 | 拖拽到右栏打开 | `desktop_drop` | ⏳ 后续 | 文件拖放到右栏直接打开编辑器 | +| 11 | 3D 模型预览 | `flutter_3d_controller` | ⏳ 后续 | 右栏嵌入 3D 模型 | +| 12 | 图片缩放查看 | `photo_view` | ⏳ 后续 | 右栏图片缩放平移 | + +*** + +## 九、跨平台兼容方案 + +### 9.1 服务抽象层架构 + +``` +lib/core/services/desktop/ +├── desktop_service_registry.dart # 服务注册表(按平台注入实现) +├── desktop_tray_service.dart # 托盘服务抽象 + TrayMenuItem 模型 +├── desktop_menu_service.dart # 原生菜单服务抽象 +├── desktop_window_effect_service.dart # 窗口特效服务抽象 +└── implementations/ + ├── tray_manager_tray_service.dart # tray_manager 实现(macOS/Win/Linux) + ├── stub_desktop_tray_service.dart # Stub 实现(iOS/Android/鸿蒙) + ├── macos_menu_bar_service.dart # macOS PlatformMenuBar 实现 + ├── macos_window_effect_service.dart # macOS 窗口特效实现 + ├── windows_acrylic_service.dart # Windows 窗口特效实现 + └── stub_window_effect_service.dart # Stub 实现(移动端) +``` + +### 9.2 平台守卫规范 + +```dart +// 所有桌面服务调用必须先判断平台 +import 'package:xianyan/core/utils/platform_utils.dart' as pu; + +if (pu.isDesktop) { + await DesktopTrayService.instance.init(); +} + +if (pu.isMacOS) { + MacosMenuBarService.register(context, ref); +} + +// ❌ 禁止使用 switch(TargetPlatform) 穷举(见 iOS_macOS_Developer_Guide.md §4.3) +``` + +### 9.3 鸿蒙端兼容 + +| 模块 | 鸿蒙端处理 | +| ----------- | -------------------------------------------------------- | +| 系统托盘 | `pu.isDesktop` 守卫,鸿蒙端不执行;stub 实现返回 `isSupported = false` | +| macOS 菜单栏 | `pu.isMacOS` 守卫,鸿蒙端不执行 | +| Windows 原生侧 | `pu.isWindows` 守卫,鸿蒙端不执行 | +| 窗口特效 | `pu.isMacOS` / `pu.isWindows` 守卫,鸿蒙端不执行 | +| 工作台设置页 | 鸿蒙端可访问页面,但桌面专属项(毛玻璃/拖拽出窗)显示为禁用状态 | + +### 9.4 pubspec 双模板同步 + +按 [iOS\_macOS\_Developer\_Guide.md](../iOS_macOS_Developer_Guide.md) §2.4 铁律: + +1. `pubspec.macos.yaml` 新增 `tray_manager` / `macos_window_utils` / `flutter_acrylic` +2. `pubspec.ohos.yaml` **不添加**(桌面专属库) +3. 三处模板同步移除 `nearby_connections` +4. 更新 §2.3 差异对照表 +5. 更新顶部更新日志 +6. 更新 CHANGELOG.md + +*** + +## 十、动态主题与动态样式 + +### 10.1 动态主题响应点 + +| 模块 | 响应主题变化 | 实现 | +| ------------ | -------------------- | ----------------------------------------- | +| 托盘图标 | 浅色/深色图标切换 | `DesktopTrayService.setIcon(isDark: ...)` | +| 托盘菜单 | 跟随系统主题 | tray\_manager 原生支持 | +| macOS 菜单栏 | 跟随系统主题 | PlatformMenuBar 原生支持 | +| macOS 窗口特效 | NSAppearance 切换 | `macos_window_utils` API | +| Windows 窗口特效 | Mica/Acrylic dark 参数 | `flutter_acrylic` API | +| Windows 标题栏 | 深色/浅色 | 已有 `setDarkMode` | +| 工作台设置页 | 跟随应用主题 | CupertinoPageScaffold 自动响应 | + +### 10.2 动态样式响应点 + +| 设计令牌 | 响应 | +| ---- | ------------------------------ | +| 主题色 | 托盘未读角标颜色、菜单选中态颜色 | +| 圆角 | Pop 菜单圆角(`--radius-lg` = 12px) | +| 字体 | 菜单项字体跟随应用字体 | +| 间距 | 菜单项间距跟随 `--space-2` = 8px | + +### 10.3 主题切换流程 + +``` +用户切换主题 + ↓ +ThemeProvider 更新 ThemeState + ↓ +app.dart 监听 ThemeState 变化 + ↓ +┌─────────────────────────────────────┐ +│ MacosPlatformService.syncTheme() │ ← 已有 +│ WindowsPlatformService.syncTheme() │ ← 已有 +│ MacosWindowService.applyEffect() │ ← 新增 +│ WindowsAcrylicService.applyEffect() │ ← 新增 +│ DesktopTrayService.setIcon() │ ← 新增 +└─────────────────────────────────────┘ +``` + +*** + +## 十一、实施计划 + +### 11.1 阶段划分 + +| 阶段 | 内容 | 涉及文件 | +| -------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------- | +| **P0-1 清理** | 移除 nearby\_connections | pubspec.yaml / pubspec.ohos.yaml / pubspec.macos.yaml | +| **P0-2 依赖** | 新增 tray\_manager / macos\_window\_utils / flutter\_acrylic | pubspec.macos.yaml | +| **P0-3 服务层** | 建立 desktop/ 服务抽象层 | lib/core/services/desktop/ | +| **P0-4 系统托盘** | 实现托盘服务 + 菜单 + 未读角标 | lib/core/services/desktop/ + app.dart | +| **P0-5 macOS 菜单栏** | PlatformMenuBar 注册业务菜单 | lib/core/services/desktop/macos\_menu\_bar\_service.dart + app.dart | +| **P0-6 Windows 原生侧** | 补齐 6 个 MethodChannel 方法 | windows/runner/ + windows\_platform\_service.dart | +| **P0-7 窗口特效** | macOS 毛玻璃 + Windows Acrylic/Mica | lib/core/services/desktop/ + main.dart | +| **P0-8 工作台设置页** | 新建独立页面 + 迁移设置项 + 扩展功能 | lib/features/settings/presentation/workbench/ | +| **P0-9 动态主题** | 接入主题切换回调 | app.dart | +| **P0-10 文档同步** | 更新 iOS\_macOS\_Developer\_Guide.md + CHANGELOG.md | docs/ | + +### 11.2 验收标准 + +* [ ] `flutter pub get` 三端无报错 + +* [ ] `dart analyze lib/` 无 error + +* [ ] macOS 运行:托盘图标显示,右键弹出 6 组菜单,未读角标正确 + +* [ ] macOS 运行:顶部菜单栏显示"闲言",业务菜单项可触发对应功能 + +* [ ] macOS 运行:侧边栏毛玻璃生效,标题栏融合 + +* [ ] Windows 运行:托盘图标显示,右键弹出菜单 + +* [ ] Windows 运行:Win11 显示 Mica 背景,Win10 显示 Acrylic + +* [ ] Windows 运行:setWindowTitle/setFullscreen/isFullscreen/setMinSize/performHapticFeedback/getSystemAppearance 均可调用 + +* [ ] 工作台设置页:4 项迁移项 + 5 项新增项均可正常切换 + +* [ ] 主题切换:托盘图标/窗口特效/菜单栏实时响应 + +* [ ] 鸿蒙端编译:无报错(桌面服务 stub 生效) + +* [ ] iOS/Android 编译:无报错(桌面服务 stub 生效) + +* [ ] nearby\_connections 已从三处模板移除 + +*** + +## 十二、风险与应对 + +| 风险 | 概率 | 影响 | 应对 | +| --------------------------------------------- | -- | ------- | -------------------------------------------------------------------------------- | +| tray\_manager 与 window\_manager 冲突 | 中 | 托盘无法初始化 | 优先初始化 window\_manager,再初始化 tray\_manager | +| flutter\_acrylic 初始化顺序错误 | 高 | 窗口特效不生效 | 严格遵循 `Window.initialize()` → `windowManager.ensureInitialized()` → `setEffect()` | +| macOS 12 以下版本 NSVisualEffectView material 不可用 | 低 | 侧边栏无模糊 | 运行时检测 `Platform.operatingSystemVersion`,低版本降级为纯色 | +| Win10 早期版本(<1809)不支持 Acrylic | 低 | 窗口无特效 | 检测 build 号,降级为纯色背景 | +| PlatformMenuBar 在某些 macOS 版本不生效 | 低 | 菜单栏显示默认 | 已确认 PlatformMenuBar 支持 macOS 10.14+,项目最低 macOS 12,无风险 | +| 托盘图标资源未准备 | 高 | 托盘无图标 | 需提前准备 `assets/images/tray_icon_light.png` 和 `tray_icon_dark.png` | +| 鸿蒙端编译报错(import 桌面库) | 中 | 鸿蒙端无法编译 | 使用条件导入 + stub 实现 | + +*** + +## 十三、待确认事项 + +> 以下事项需在实施前确认: + +1. **托盘图标资源**:是否已有 `assets/images/tray_icon_*.png`?若无,是否使用 app 图标作为托盘图标? +2. **macOS 应用最低版本**:当前 macOS deployment target 是多少?需确认 ≥ 12.0 +3. **Windows 最低版本**:当前 Windows 最低支持版本?需确认 ≥ Win10 1809(Acrylic 最低要求) +4. **未读角标 Provider**:`dailySentenceProvider` 和 `readLaterProvider` 是否已有 `unreadCount` 字段?若无需新增 +5. **工作台设置页路由**:`AppRoutes.workbenchSettings` 的路径值(建议 `/settings/workbench`) +6. **专注阅读模式**:隐藏中栏+导航栏后,如何退出?(建议:右栏顶部显示悬浮"退出专注"按钮,或按 Esc 键) + +*** + +*文档维护者:闲言 APP 开发团队 | 创建时间:2026-06-18 | 状态:待评审* diff --git a/docs/pc-workbench-spec.md b/docs/pc-workbench-spec.md new file mode 100644 index 00000000..536c6143 --- /dev/null +++ b/docs/pc-workbench-spec.md @@ -0,0 +1,236 @@ +# PC 工作台布局重构开发文档 + +> 创建时间: 2026-06-18 +> 状态: 实施中 +> 类型: spec 文档(开发完成后删除) + +## 一、功能描述 + +将 macOS/Web/Windows/Pad 横屏宽屏布局重构为微信 PC 式三栏工作台,核心解决"右栏显示阉割版面板"问题,改为通过嵌套 Navigator 显示完整原始页面。 + +## 二、核心约束 + +1. **右栏必须显示完整原始页面**,不得生成模拟阉割版面板 +2. **左侧菜单栏位置**在通用设置页面可设置(left/right/top/bottom) +3. **横屏宽屏自动开启**工作台模式 +4. **支持动态主题和动态样式** +5. **编辑器单独全屏**+左侧菜单栏保留 +6. **弹窗 Dialog 居中**,Sheet 和部分选择栏单独一个页面 + +## 三、设计决策 + +| 决策项 | 选择 | +|---|---| +| 布局架构 | 微信 PC 式三栏工作台 | +| 断点 | 对齐设计规则 768/1024/1280 | +| 导航栏形态 | 用户可切换位置(left/right/top/bottom) | +| 右栏默认 | 概览仪表盘 | +| 页面承载 | 嵌套 Navigator 双视图 | +| 中栏宽度 | 可拖拽调整+持久化(320-600px,默认 380px) | +| 返回导航 | 右栏顶部返回按钮+Esc/Ctrl+← 快捷键 | +| 过渡策略 | 窄屏 push+宽屏并列自动迁移 | +| 全屏页面 | 编辑器单独全屏+左侧菜单栏 | +| 页面接入 | 路由拦截自动适配 | +| 状态保持 | 每 Tab 独立栈+状态保持 | +| 弹窗位置 | Dialog 居中全窗口,Sheet 单独页面 | + +## 四、界面设计 + +### 4.1 三栏布局结构 + +``` +┌──────┬────────────┬──────────────────────────┐ +│ 导航 │ 内容列表 │ 内容详情 │ +│ 72px │ 380px可调 │ 自适应 │ +│ │ │ │ +│ 🏠 │ ┌────────┐ │ ┌──────────────────────┐ │ +│ 🧭 │ │ 列表项1 │ │ │ 返回 ‹ 标题 ⋯ │ │ +│ 👤 │ │ 列表项2 │ │ ├──────────────────────┤ │ +│ ⚙️ │ │ 列表项3 │ │ │ │ │ +│ │ └────────┘ │ │ 完整原始页面 │ │ +│ │ │ │ (非阉割版) │ │ +│ │ │ │ │ │ +└──────┴────────────┴──────────────────────────┘ +``` + +### 4.2 响应式断点 + +| 宽度 | 模式 | 布局 | +|---|---|---| +| ≥1280px | 三栏完整 | 导航+列表+详情 | +| 1024-1279px | 三栏紧凑 | 中栏 340px | +| 768-1023px | 双栏 | 导航+详情(列表折叠) | +| <768px | 单栏 | 底部导航+全屏内容 | + +### 4.3 右栏页面栈 + +- 每个 Tab 维护独立的右栏页面栈 +- 栈深>1 时显示返回按钮 +- Esc/Ctrl+← 返回上一级 +- 切换 Tab 时各栈独立保持 + +## 五、技术方案 + +### 5.1 嵌套 Navigator 双视图 + +**核心思路**:每个 Tab 分支的 Navigator 同时驱动中栏和右栏。 + +``` +StatefulShellRoute.indexedStack + ├─ homeBranch (Navigator) + │ ├─ 中栏: HomePage (栈底,always show) + │ └─ 右栏: 栈顶页面 (push 时显示) + ├─ discoverBranch (Navigator) + │ ├─ 中栏: DiscoverPage + │ └─ 右栏: 栈顶页面 + └─ profileBranch (Navigator) + ├─ 中栏: ProfilePage + └─ 右栏: 栈顶页面 +``` + +**实现方式**: +- 使用 `Navigator.of(branchKey).push()` 在分支内 push +- 中栏显示 `branchNavigator.stack[0]`(栈底页面) +- 右栏显示 `branchNavigator.stack.last`(栈顶页面) +- 通过 `Offstage` + `TickerMode` 保持非当前 Tab 的状态 + +### 5.2 路由拦截自动适配 + +**拦截点**:`app_nav_extension.dart` 的 `appPush()` 方法 + +```dart +extension AppNavExtension on BuildContext { + void appPush(String route, {Object? extra}) { + if (isWidescreen && !RouteRegistry.isFullScreenRoute(route)) { + // 宽屏:在当前 Tab 分支的 Navigator 内 push → 右栏显示 + _pushToBranchNavigator(route, extra); + } else { + // 窄屏或全屏路由:走 rootNavigator push + appRouter.push(route, extra: extra); + } + RecentRouteService.addRecentRoute(route); + } +} +``` + +### 5.3 全屏路由标记 + +在 `RouteDef` 增加 `fullScreen` 字段: +```dart +class RouteDef { + final bool fullScreen; // 编辑器/图片预览等需全屏的页面 + // ... +} +``` + +编辑器路由注册时标记 `fullScreen: true`。 + +### 5.4 断点统一 + +```dart +// 旧 +const kSplitViewBreakpoint = 900.0; +const kTripleColumnBreakpoint = 1400.0; + +// 新(对齐设计规则) +const kCompactBreakpoint = 768.0; // 单栏→双栏 +const kMediumBreakpoint = 1024.0; // 双栏→三栏 +const kExpandedBreakpoint = 1280.0; // 三栏完整 +``` + +### 5.5 工作台模式自动开启 + +```dart +bool get isWorkbenchMode { + if (!pu.isDesktop && !pu.isWeb) return false; + final width = MediaQuery.sizeOf(context).width; + return width >= kCompactBreakpoint; +} +``` + +## 六、交互逻辑 + +### 6.1 键盘快捷键 + +| 快捷键 | 功能 | +|---|---| +| Ctrl/Cmd + 1/2/3 | 切换 Tab | +| Ctrl/Cmd + W | 关闭右栏当前页面 | +| Esc | 右栏返回上一级 | +| Ctrl/Cmd + ← | 右栏后退 | +| Ctrl/Cmd + → | 右栏前进 | +| Ctrl/Cmd + K | 唤起 Spotlight 搜索 | + +### 6.2 拖拽分割条 + +- 中栏宽度可拖拽调整(320-600px) +- 宽度持久化到 KvStorage +- 拖拽时分割条高亮 + +### 6.3 窄屏→宽屏自动迁移 + +- 窗口缩放达到断点时自动切换布局 +- 当前 push 的页面自动迁移到右栏 +- 页面状态保持 + +## 七、实施计划 + +### 阶段 1: 核心架构 +- [ ] 1.1 统一断点常量(768/1024/1280) +- [ ] 1.2 RouteDef 增加 fullScreen 字段 +- [ ] 1.3 标记编辑器类路由为 fullScreen +- [ ] 1.4 实现嵌套 Navigator 双视图核心组件 + +### 阶段 2: 布局重构 +- [ ] 2.1 重构 AppShell 为工作台布局 +- [ ] 2.2 实现三栏布局(导航+列表+详情) +- [ ] 2.3 实现双栏布局(导航+详情) +- [ ] 2.4 实现单栏布局(底部导航) +- [ ] 2.5 断点自动切换 + +### 阶段 3: 路由拦截 +- [ ] 3.1 改造 appPush 为双模式(宽屏分支 push / 窄屏 root push) +- [ ] 3.2 实现右栏页面栈管理 +- [ ] 3.3 实现右栏返回按钮 +- [ ] 3.4 实现键盘快捷键(Esc/Ctrl+←/→) + +### 阶段 4: 交互增强 +- [ ] 4.1 实现可拖拽分割条+宽度持久化 +- [ ] 4.2 实现窄屏→宽屏自动迁移 +- [ ] 4.3 右栏默认显示概览仪表盘 +- [ ] 4.4 每 Tab 独立栈状态保持 + +### 阶段 5: 设置集成 +- [ ] 5.1 通用设置增加"工作台"分组 +- [ ] 5.2 导航栏位置设置(已有,验证) +- [ ] 5.3 工作台模式开关 +- [ ] 5.4 中栏宽度重置 + +### 阶段 6: 清理与审计 +- [ ] 6.1 删除阉割版面板文件(RightPanelRegistry 等) +- [ ] 6.2 删除死代码(SmartAppBar/PanelBookmark) +- [ ] 6.3 编译验证 +- [ ] 6.4 运行日志审计 +- [ ] 6.5 CHANGELOG 更新 + +## 八、修改文件清单 + +### 新增 +- `lib/core/layout/workbench/nested_navigator_view.dart` — 嵌套 Navigator 双视图 +- `lib/core/layout/workbench/right_panel_navigator.dart` — 右栏页面栈管理 +- `lib/core/layout/workbench/workbench_layout.dart` — 工作台布局组件 + +### 修改 +- `lib/core/layout/adaptive_split_view.dart` — 断点常量统一 +- `lib/core/router/route_def.dart` — 增加 fullScreen 字段 +- `lib/core/router/route_registry.dart` — 标记 fullScreen 路由 +- `lib/core/router/app_nav_extension.dart` — 路由拦截双模式 +- `lib/app/layout/app_shell.dart` — 重构为工作台布局 +- `lib/core/providers/split_view_provider.dart` — 断点常量+工作台状态 +- `lib/features/settings/presentation/general/general_settings_sections.dart` — 工作台设置分组 + +### 删除 +- `lib/core/layout/right_panel_registry.dart` — 阉割版面板注册表 +- `lib/core/layout/smart_app_bar.dart` — 死代码 +- `lib/core/layout/panel_bookmark.dart` — 死代码 +- `lib/core/layout/triple_column_view.dart` — 被新工作台布局替代 diff --git a/docs/toolsapi/API_ANALYSIS.md b/docs/toolsapi/API_ANALYSIS.md deleted file mode 100644 index 8e25a203..00000000 --- a/docs/toolsapi/API_ANALYSIS.md +++ /dev/null @@ -1,699 +0,0 @@ -# 🔍 网站API接口分析文档 - -> 项目:在线工具箱网站 -> 框架:ThinkPHP 5 + FastAdmin -> 生成日期:2026-04-26 - ---- - -## 📌 项目概况 - -本项目是一个基于 **ThinkPHP 5 + FastAdmin** 框架的 **在线工具箱网站**,采用多模块架构,包含三大核心模块: - -| 模块 | 入口文件 | 类型 | 说明 | -|------|---------|------|------| -| `index` | `public/index.php` | 前端(SSR页面渲染) | 面向用户的前台页面 | -| `admin` | `public/admin.php` | 后端(管理后台) | 面向管理员的后台管理 | -| `api` | `public/index.php` | 前端(RESTful API) | 面向APP/小程序的JSON接口 | - ---- - -## 一、🟢 前端 JSON API(`api` 模块) - -入口路径:`/api/控制器/方法`,返回 JSON 数据,主要用于小程序/APP 调用。 - -### 1️⃣ 用户认证类 — `User` 控制器 - -| 接口路径 | 方法 | 功能 | 需登录 | -|---------|------|------|--------| -| `/api/user/login` | POST | 账号密码登录 | ❌ | -| `/api/user/mobilelogin` | POST | 手机验证码登录 | ❌ | -| `/api/user/register` | POST | 用户注册 | ❌ | -| `/api/user/logout` | POST | 退出登录 | ✅ | -| `/api/user/index` | GET | 会员中心 | ✅ | -| `/api/user/profile` | POST | 修改个人信息(头像/用户名/昵称/简介) | ✅ | -| `/api/user/changeemail` | POST | 修改邮箱 | ❌ | -| `/api/user/changemobile` | POST | 修改手机号 | ❌ | -| `/api/user/resetpwd` | POST | 重置密码(支持手机/邮箱) | ❌ | -| `/api/user/third` | POST | 第三方登录(微信/QQ等) | ❌ | - -### 2️⃣ Token管理类 — `Token` 控制器 - -| 接口路径 | 方法 | 功能 | 需登录 | -|---------|------|------|--------| -| `/api/token/check` | GET | 检测Token是否过期 | ✅ | -| `/api/token/refresh` | GET | 刷新Token | ✅ | - -### 3️⃣ 公共服务类 — `Common` 控制器 - -| 接口路径 | 方法 | 功能 | 需登录 | -|---------|------|------|--------| -| `/api/common/init` | POST | 加载初始化配置(版本/上传/城市/封面) | ❌ | -| `/api/common/upload` | POST | 文件上传(支持分片上传) | ✅ | -| `/api/common/captcha` | GET | 获取验证码图片 | ❌ | - -### 4️⃣ 短信验证类 — `Sms` 控制器 - -| 接口路径 | 方法 | 功能 | 需登录 | -|---------|------|------|--------| -| `/api/sms/send` | POST | 发送手机验证码 | ❌ | -| `/api/sms/check` | POST | 校验手机验证码 | ❌ | - -### 5️⃣ 邮箱验证类 — `Ems` 控制器 - -| 接口路径 | 方法 | 功能 | 需登录 | -|---------|------|------|--------| -| `/api/ems/send` | POST | 发送邮箱验证码 | ❌ | -| `/api/ems/check` | POST | 校验邮箱验证码 | ❌ | - -### 6️⃣ 数据验证类 — `Validate` 控制器 - -| 接口路径 | 方法 | 功能 | 需登录 | -|---------|------|------|--------| -| `/api/validate/check_email_available` | POST | 检测邮箱是否可用 | ❌ | -| `/api/validate/check_username_available` | POST | 检测用户名是否可用 | ❌ | -| `/api/validate/check_nickname_available` | POST | 检测昵称是否可用 | ❌ | -| `/api/validate/check_mobile_available` | POST | 检测手机号是否可用 | ❌ | -| `/api/validate/check_mobile_exist` | POST | 检测手机号是否存在 | ❌ | -| `/api/validate/check_email_exist` | POST | 检测邮箱是否存在 | ❌ | -| `/api/validate/check_sms_correct` | POST | 校验短信验证码 | ❌ | -| `/api/validate/check_ems_correct` | POST | 校验邮箱验证码 | ❌ | - -### 7️⃣ 汉字/语言查询类 — `Hanzi` 控制器 - -| 接口路径 | 方法 | 功能 | 需登录 | -|---------|------|------|--------| -| `/api/hanzi/zi` | POST | 汉字查询 | ❌ | -| `/api/hanzi/bishun` | POST | 汉字笔顺查询 | ❌ | -| `/api/hanzi/zuci` | POST | 汉字组词查询 | ❌ | -| `/api/hanzi/cidian` | POST | 词典查询 | ❌ | -| `/api/hanzi/chengyu` | POST | 成语查询 | ❌ | -| `/api/hanzi/jinyici` | POST | 近义词查询 | ❌ | -| `/api/hanzi/fanyici` | POST | 反义词查询 | ❌ | -| `/api/hanzi/juzi` | POST | 句子大全查询 | ❌ | -| `/api/hanzi/word` | POST | 英文单词查询 | ❌ | -| `/api/hanzi/suoxie` | POST | 英文缩写查询 | ❌ | -| `/api/hanzi/pinyin` | GET | 汉字转拼音(支持声调/首字母/大写等选项) | ❌ | -| `/api/hanzi/airport` | POST | 机场搜索 | ❌ | -| `/api/hanzi/port` | POST | 港口搜索 | ❌ | -| `/api/hanzi/herbal` | POST | 中药材搜索 | ❌ | -| `/api/hanzi/raokouling` | POST | 绕口令搜索 | ❌ | -| `/api/hanzi/duilian` | POST | 对联搜索 | ❌ | -| `/api/hanzi/shenfenzheng` | POST | 身份证归属地查询 | ❌ | -| `/api/hanzi/jizhuanwan` | POST | 脑筋急转弯搜索 | ❌ | -| `/api/hanzi/uppercase` | POST | 人民币转大写 | ❌ | -| `/api/hanzi/great` | POST | 人民币转美元大写 | ❌ | -| `/api/hanzi/unit` | POST | 长度单位换算 | ❌ | -| `/api/hanzi/temp` | POST | 温度单位换算 | ❌ | -| `/api/hanzi/power` | POST | 功率单位换算 | ❌ | -| `/api/hanzi/speed` | POST | 速度单位换算 | ❌ | -| `/api/hanzi/weight` | POST | 重量单位换算 | ❌ | -| `/api/hanzi/area` | POST | 面积单位换算 | ❌ | -| `/api/hanzi/volume` | POST | 体积单位换算 | ❌ | -| `/api/hanzi/pressure` | POST | 压力单位换算 | ❌ | -| `/api/hanzi/qtrip` | POST | 油耗计算 | ❌ | -| `/api/hanzi/bmi` | POST | BMI指数计算 | ❌ | -| `/api/hanzi/gsw` | POST | 古诗文搜索 | ❌ | -| `/api/hanzi/efs` | POST | 歇后语搜索 | ❌ | -| `/api/hanzi/zgjm` | POST | 周公解梦搜索 | ❌ | -| `/api/hanzi/mrmy` | POST | 名人名言搜索 | ❌ | -| `/api/hanzi/jibing` | POST | 疾病自查搜索 | ❌ | -| `/api/hanzi/shiwu` | POST | 食物相生相克搜索 | ❌ | -| `/api/hanzi/gushi` | POST | 故事搜索 | ❌ | -| `/api/hanzi/pianfang` | POST | 偏方搜索 | ❌ | -| `/api/hanzi/jiufang` | POST | 酒方搜索 | ❌ | -| `/api/hanzi/tisana` | POST | 药茶搜索 | ❌ | -| `/api/hanzi/zuowen` | POST | 作文搜索 | ❌ | -| `/api/hanzi/why` | POST | 十万个为什么搜索 | ❌ | -| `/api/hanzi/site` | POST | 网站搜索 | ❌ | -| `/api/hanzi/saying` | POST | 谚语搜索 | ❌ | -| `/api/hanzi/drug` | POST | 药品查询 | ❌ | -| `/api/hanzi/nickData` | POST | 网名大全 | ❌ | -| `/api/hanzi/changshi` | POST | 生活常识搜索 | ❌ | -| `/api/hanzi/lyric` | POST | 歌词搜索 | ❌ | -| `/api/hanzi/phpFunc` | POST | PHP函数搜索 | ❌ | - -### 8️⃣ 热榜采集类 — `Hot` 控制器 - -| 接口路径 | 方法 | 功能 | 需登录 | -|---------|------|------|--------| -| `/api/hot/BaiDuHot` | GET | 百度热搜采集 | ❌ | -| `/api/hot/WeiBoHot` | GET | 微博热搜采集 | ❌ | -| `/api/hot/ZhiHuHot` | GET | 知乎热榜采集 | ❌ | -| `/api/hot/DouYinHot` | GET | 抖音热榜采集 | ❌ | -| `/api/hot/SouSouHot` | GET | 360热搜采集 | ❌ | -| `/api/hot/SouGouHot` | GET | 搜狗热搜采集 | ❌ | -| `/api/hot/TouTiaoHot` | GET | 今日头条热榜采集 | ❌ | -| `/api/hot/BiliBiliHot` | GET | 哔哩哔哩排行榜采集 | ❌ | -| `/api/hot/CsdnHot` | GET | CSDN热搜采集 | ❌ | -| `/api/hot/KeHot` | GET | 36氪推荐采集 | ❌ | -| `/api/hot/JueJinHot` | GET | 稀土掘金热榜采集 | ❌ | -| `/api/hot/XueQiuHot` | GET | 雪球热榜采集 | ❌ | -| `/api/hot/ItHot` | GET | IT之家热榜采集 | ❌ | -| `/api/hot/PengPaiHot` | GET | 澎湃热榜采集 | ❌ | -| `/api/hot/TengXunHot` | GET | 腾讯热点采集 | ❌ | -| `/api/hot/AcFunHot` | GET | AcFun排行榜采集 | ❌ | - -### 9️⃣ 站长工具类 — `Webapi` 控制器 - -| 接口路径 | 方法 | 功能 | 需登录 | -|---------|------|------|--------| -| `/api/webapi/getTDK` | GET | 获取网站TDK信息(标题/关键词/描述/ICO) | ❌ | -| `/api/webapi/BaiDuPuTongTuiSong` | GET | 百度普通推送 | ❌ | -| `/api/webapi/BingTuiSong` | GET | 必应推送 | ❌ | -| `/api/webapi/ShenMaTuiSong` | GET | 神马推送 | ❌ | -| `/api/webapi/linkData` | GET | 外链数据 | ❌ | -| `/api/webapi/submitYourSite` | POST | 网站提交 | ❌ | -| `/api/webapi/siteHits` | POST | 网站点击量统计 | ❌ | -| `/api/webapi/checkDomainPort` | GET | 端口检测 | ❌ | -| `/api/webapi/GetHeaders` | POST | 获取网页头部信息 | ❌ | -| `/api/webapi/RandUA` | POST | 随机UA生成 | ❌ | -| `/api/webapi/ImgCompress` | POST | 图片压缩 | ❌ | -| `/api/webapi/ocr` | POST | OCR图片识别(百度API) | ❌ | -| `/api/webapi/curtain` | POST | 窗帘计算器 | ❌ | -| `/api/webapi/tiles` | POST | 地砖计算器 | ❌ | -| `/api/webapi/wallpaper` | POST | 壁纸计算器 | ❌ | -| `/api/webapi/floors` | POST | 地板计算器 | ❌ | - -### 🔟 AI内容生成类 — `Generate` 控制器 - -> 基于 ChatGPT API 的内容自动生成,生成后写入数据库 - -| 接口路径 | 方法 | 功能 | 需登录 | -|---------|------|------|--------| -| `/api/generate/shool` | GET | AI生成绕口令 | ❌ | -| `/api/generate/brainteaser` | GET | AI生成谜语 | ❌ | -| `/api/generate/jfc` | GET | AI生成近反义词 | ❌ | -| `/api/generate/couplet` | GET | AI生成对联 | ❌ | -| `/api/generate/jzdq` | GET | AI生成句子 | ❌ | -| `/api/generate/efs` | GET | AI生成歇后语 | ❌ | -| `/api/generate/mrmy` | GET | AI生成名人名言 | ❌ | -| `/api/generate/gushi` | GET | AI生成故事 | ❌ | -| `/api/generate/jibing` | GET | AI生成疾病信息 | ❌ | - -### 1️⃣1️⃣ 其他工具类接口 - -| 控制器 | 接口路径 | 方法 | 功能 | 需登录 | -|--------|---------|------|------|--------| -| `Commonweal` | `/api/commonweal/almanac` | GET | 万年历/黄历查询(支持阳历/阴历) | ❌ | -| `Commonweal` | `/api/commonweal/getFaviconUrl` | GET | 获取网站Favicon图标 | ❌ | -| `Ipcheck` | `/api/ipcheck/check` | POST | IP端口检测(TCP/ICMP) | ❌ | -| `Imghosting` | `/api/imghosting/ImageUpload` | POST | 图床上传(支持sm.ms/Picgo) | ❌ | -| `Batch` | `/api/batch/test` | GET | 批量数据采集测试 | ❌ | -| `Batch` | `/api/batch/daoru` | GET | JSON数据批量导入 | ❌ | - -### 1️⃣2️⃣ 内部辅助方法(`Demo` 控制器) - -| 方法 | 功能 | 说明 | -|------|------|------| -| `sendPostRequest` | ChatGPT API请求封装 | 供 Generate 控制器调用 | -| `remoteImageUploadAndSave` | 远程图片下载保存 | 供其他控制器调用 | - ---- - -## 二、🔵 前端页面路由(`index` 模块) - -入口路径:通过 `application/route.php` 定义,返回 HTML 页面。 - -### 页面控制器 - -| 控制器 | 功能 | -|--------|------| -| `Index` | 首页 | -| `Classify` | 工具分类页 | -| `Tool` | 各工具页面(约200+个工具) | -| `Details` | 各工具详情页(数据详情展示) | -| `Sol` | 分类筛选页(按国家/朝代/类型等筛选) | -| `Search` | 站内搜索页 | -| `Link` | 友情链接页 | -| `About` | 关于/隐私政策页 | -| `Ajax` | 异步请求(语言包加载/文件上传) | -| `User` | 前台用户中心 | - -### 工具分类路由(6大类,200+工具) - -#### 📚 教育学习 - -| 路由 | 功能 | 详情路由 | -|------|------|---------| -| `/hanzi` | 汉语字典 | `/hanzi/:id` | -| `/bishun` | 汉字笔顺 | `/bishun/:id` | -| `/zuci` | 汉字组词 | `/zuci/:id` | -| `/cidian` | 汉语词典 | `/cidian/:id` | -| `/chengyu` | 成语大全 | `/chengyu/:id` | -| `/jinyici` | 近义词大全 | `/jinyici/:id` | -| `/fanyici` | 反义词大全 | `/fanyici/:id` | -| `/juzi` | 句子大全 | `/juzi/:id` | -| `/fanyi` | 在线翻译(32种语言) | `/fanyi/:language` | -| `/danci` | 英语单词 | `/danci/:id` | -| `/suoxie` | 英文缩写 | `/suoxie/:id` | -| `/riddle` | 谜语大全 | `/riddle/:id` | -| `/hanzizhuanpinyin` | 汉字转拼音 | — | -| `/jianfan` | 繁体转简体 | — | -| `/poetry` | 古诗词 | `/poetry/:id` | -| `/mrmy` | 名人名言 | `/mrmy/:id` | -| `/zuowen` | 作文大全 | `/zuowen/:id` | -| `/zimu` | 26字母 | — | -| `/lakh_why` | 十万个为什么 | `/lakh_why/:id` | -| `/lscd` | 历史朝代 | — | -| `/minority` | 少数民族分布表 | — | -| `/periodic_table` | 元素周期表 | — | -| `/sanshiliuji` | 三十六计 | `/sanshiliuji/:id` | -| `/zizhitongjian` | 资治通鉴 | `/zizhitongjian/:id` | -| `/warring` | 战国策 | `/warring/:id` | -| `/lunyu` | 论语 | `/lunyu/:id` | -| `/baizhanqilue` | 百战奇略 | `/baizhanqilue/:id` | -| `/sunzibingfa` | 孙子兵法 | `/sunzibingfa/:id` | -| `/sanguozhi` | 三国志 | `/sanguozhi/:id` | -| `/shangshu` | 尚书 | `/shangshu/:id` | -| `/sunbinbingfa` | 孙膑兵法 | `/sunbinbingfa/:id` | -| `/techartgroup` | 天工开物 | `/techartgroup/:id` | -| `/lvshichunqiu` | 吕氏春秋 | `/lvshichunqiu/:id` | -| `/jingangjing` | 金刚经 | `/jingangjing/:id` | -| `/zhouyi` | 周易 | `/zhouyi/:id` | -| `/aljj` | 鬼谷子 | `/aljj/:id` | -| `/shiji` | 史记 | `/shiji/:id` | -| `/zhuangzi` | 庄子 | `/zhuangzi/:id` | -| `/mengzi` | 孟子 | `/mengzi/:id` | -| `/zhongyong` | 中庸 | `/zhongyong/:id` | -| `/hanfeizi` | 韩非子 | `/hanfeizi/:id` | -| `/weiluyehua` | 围炉夜话 | `/weiluyehua/:id` | -| `/zuozhuan` | 左传 | `/zuozhuan/:id` | -| `/mingshi` | 明史 | `/mingshi/:id` | -| `/surname` | 百家姓 | `/surname/:id` | -| `/sanzijing` | 三字经 | `/sanzijing/:id` | -| `/chengfabiao` | 乘法口诀表 | — | -| `/qianziwen` | 千字文 | — | -| `/dizigui` | 弟子规 | — | - -#### 🏠 生活服务 - -| 路由 | 功能 | 详情路由 | -|------|------|---------| -| `/airport` | 机场代码大全 | `/airport/:id` | -| `/gangkou` | 港口大全 | `/gangkou/:id` | -| `/huangli` | 黄历 | `/huangli/:calendar` | -| `/zhongyaocai` | 中药材 | `/zhongyaocai/:id` | -| `/raokouling` | 绕口令 | `/raokouling/:id` | -| `/duilian` | 对联大全 | `/duilian/:id` | -| `/id_soft` | 身份证归属地 | — | -| `/jizhuanwan` | 脑筋急转弯 | `/jizhuanwan/:id` | -| `/xiehouyu` | 歇后语 | `/xiehouyu/:id` | -| `/zgjm` | 周公解梦 | `/zgjm/:id` | -| `/jbzc` | 疾病自查 | `/jbzc/:id` | -| `/gushi` | 故事大全 | `/gushi/:id` | -| `/site` | 实用网站 | `/site/:id` | -| `/symbol` | 特殊符号 | — | -| `/ocr` | OCR图片识别 | — | -| `/today` | 历史上的今天 | `/today/:today` | -| `/typewriting` | 中文打字工具 | — | -| `/saying` | 谚语 | `/saying/:id` | -| `/car_sign` | 车标大全 | — | -| `/nevoid_phase` | 痣相图解 | — | -| `/zebra_time` | 世界时间 | — | -| `/nick` | 网名大全 | — | -| `/taglia` | 尺码对照表 | — | -| `/changshi` | 生活常识 | `/changshi/:id` | -| `/lyric` | 歌词大全 | `/lyric/:id` | -| `/hot` | 今日热榜 | `/hot/:id` | -| `/bazi` | 生辰八字计算器 | — | -| `/age` | 年龄计算器 | — | -| `/barcode` | 商品条形码 | — | -| `/jieqi` | 二十四节气 | `/jieqi/:id` | -| `/foli` | 佛理月历 | — | -| `/daoli` | 道历月历 | — | -| `/bazipaipan` | 八字排盘 | — | -| `/lipstick` | 口红颜色大全 | — | -| `/alarms` | 在线闹钟 | — | -| `/screentest` | 屏幕测试 | — | -| `/xingjiaozishi` | 性爱姿势 | `/xingjiaozishi/:id` | -| `/country` | 国家地区查询 | `/country/:id` | -| `/shengxiao` | 十二生肖 | `/shengxiao/:id` | -| `/zaomatou` | 灶马头 | — | -| `/mingpian` | 微信名片生成 | — | -| `/stopwatch` | 秒表计时器 | — | -| `/necktie` | 领带打法 | — | -| `/jiugongge` | 九格宫切图 | — | -| `/rec_audio` | 录制MP3音频 | — | -| `/led` | 跑马灯LED滚动 | — | -| `/shortcut` | 创建网页快捷方式 | — | -| `/named` | 古诗文起名 | — | - -#### 🧮 实用计算 - -| 路由 | 功能 | 详情路由 | -|------|------|---------| -| `/rmbzdx` | 人民币转大写 | — | -| `/rmbzmydx` | 人民币转美元大写 | — | -| `/unit` | 单位换算 | — | -| `/statistics` | 字数统计 | — | -| `/calculator` | 计算器 | — | -| `/qtrip` | 油耗计算器 | `/qtrip/:id` | -| `/loan` | 贷款计算器 | — | -| `/loans` | 房贷计算器 | — | -| `/curtain` | 窗帘计算器 | — | -| `/tiles` | 地砖计算器 | — | -| `/wallpaper` | 壁纸计算器 | — | -| `/floors` | 地板计算器 | — | -| `/brick` | 墙砖计算器 | — | -| `/paint` | 涂料计算器 | — | -| `/decoration` | 装修总预算 | — | -| `/car_insurance` | 车险计算器 | — | -| `/aquarium` | 鱼缸计算器 | — | -| `/relative` | 亲戚计算器 | — | -| `/vaccine` | 宝宝疫苗接种 | — | -| `/snsn` | 生男生女预测 | — | -| `/abo` | 血型遗传规律表 | — | -| `/drinking` | 喝水计算器 | — | -| `/concrete` | 混凝土计算器 | — | -| `/trigonometric` | 三角函数计算器 | — | -| `/hexagon` | 正六角柱体计算器 | — | -| `/prepayment` | 提前还贷计算器 | — | -| `/wuxianyijin` | 五险一金计算器 | — | -| `/fuli` | 复利计算器 | — | -| `/lixi` | 利息计算器 | — | -| `/zhinajin` | 滞纳金计算器 | — | -| `/susongfei` | 诉讼费计算器 | — | -| `/grsds` | 个人所得税计算器 | — | -| `/nzjgrsds` | 年终奖个税计算器 | — | -| `/percentage` | 百分比计算器 | — | -| `/mortgage` | 按揭贷款计算器 | — | -| `/zhengcunlingqu` | 整存零取计算器 | — | -| `/lingcunzhengqu` | 零存整取计算器 | — | -| `/zhengcunzhengqu` | 整存整取计算器 | — | -| `/gongjijin` | 公积金贷款计算器 | — | -| `/ershoufang` | 二手房贷款计算器 | — | -| `/angle` | 角度计算器 | — | -| `/cube_root` | 立方根计算器 | — | -| `/variance` | 方差计算器 | — | -| `/drsjcs` | 电容时间常数计算器 | — | -| `/jueduizhi` | 绝对值计算器 | — | -| `/bosongfenbu` | 泊松分布计算器 | — | -| `/ziranduishu` | 自然对数计算器 | — | -| `/jjjsq` | 交集计算器 | — | -| `/bnljsq` | 伯努利不等式计算器 | — | -| `/sjc` | 双阶乘计算器 | — | -| `/subtraction` | 差集计算器 | — | -| `/modulo` | 求模计算器 | — | - -#### 💪 健康生活 - -| 路由 | 功能 | 详情路由 | -|------|------|---------| -| `/bmi` | BMI指数 | `/bmi/:id` | -| `/shiwu` | 食物相生相克 | `/shiwu/:id` | -| `/pianfang` | 民间偏方 | `/pianfang/:id` | -| `/jiufang` | 酒方大全 | `/jiufang/:id` | -| `/tisana` | 药茶大全 | `/tisana/:id` | -| `/drug` | 药品查询 | `/drug/:id` | -| `/muscle` | 人体肌肉图解 | — | -| `/weights` | 标准体重计算器 | — | -| `/tbsa` | 身体表面积计算器 | — | -| `/edd` | 预产期计算器 | — | -| `/safe_period` | 安全期计算器 | — | -| `/huangdineijing` | 黄帝内经 | `/huangdineijing/:id` | -| `/yangsheng` | 养生时间对照表 | — | - -#### 🌐 站长工具 - -| 路由 | 功能 | 详情路由 | -|------|------|---------| -| `/suijimima` | 随机密码生成 | — | -| `/utf_8` | UTF-8编码转换 | — | -| `/rgb` | RGB颜色对照表 | — | -| `/urljiema` | URL编码解码 | — | -| `/unicode` | Unicode编码转换 | — | -| `/zfzascii` | 字符串与ASCII转换 | — | -| `/md5jiami` | MD5批量加密 | — | -| `/jinzhi` | 进制转换器 | — | -| `/unix` | Unix时间戳转换 | — | -| `/base64` | BASE64编码解码 | — | -| `/colors` | HTML颜色代码表 | — | -| `/morse_code` | 摩斯密码 | — | -| `/aes` | AES加密解密 | — | -| `/des` | DES加密解密 | — | -| `/rc4` | RC4加密解密 | — | -| `/rsa` | RSA加密解密 | — | -| `/sha` | SHA加密 | — | -| `/random` | 随机数生成器 | — | -| `/jsjiamijiemi` | JS加密解密 | — | -| `/jsyasuo` | JS代码压缩 | — | -| `/jshunxiao` | JS混淆 | — | -| `/jsgeshihua` | JS格式化 | — | -| `/htmljs` | HTML转JS | — | -| `/htmlyasuo` | HTML压缩 | — | -| `/htmlgeshihua` | HTML格式化 | — | -| `/cssyasuo` | CSS压缩 | — | -| `/cssgeshihua` | CSS格式化 | — | -| `/xmlyasuo` | XML压缩 | — | -| `/xmlgeshihua` | XML格式化 | — | -| `/jsonyasuo` | JSON压缩 | — | -| `/jsongeshihua` | JSON格式化 | — | -| `/httpcode` | HTTP状态码 | — | -| `/user_agent` | UA查看 | — | -| `/rand_user_agent` | UA生成器 | — | -| `/baidutuisong` | 百度推送 | — | -| `/bingtuisong` | 必应推送 | — | -| `/smtuisong` | 神马推送 | — | -| `/qr` | 二维码生成 | — | -| `/qr_parse` | 二维码解析 | — | -| `/outside_chain` | 超级外链工具 | — | -| `/regex` | 正则表达式 | — | -| `/get_port` | 端口检测 | — | -| `/ip_checker` | IP检查 | — | -| `/get_header` | 网页头部信息 | — | -| `/photo_compression` | 图片压缩 | — | -| `/batch_url` | 批量打开网址 | — | -| `/robots` | Robots生成 | — | -| `/uuid` | UUID生成 | — | -| `/highlight` | 代码着色高亮 | — | -| `/sql` | SQL压缩/格式化 | — | -| `/image_to_base` | 图片转Base64 | — | -| `/php_func` | PHP函数大全 | `/php_func/:id` | -| `/barcode_generation` | 条形码生成器 | — | -| `/crawler` | 爬虫模拟抓取 | — | -| `/js_operation` | JS在线运行 | — | -| `/vertical` | 文字竖排 | — | -| `/linuxs` | Linux命令大全 | — | -| `/html_table` | HTML表格生成器 | — | -| `/site_thumbnail` | 网站缩略图 | — | -| `/favicon` | ICO图标制作 | — | -| `/stringarray` | 字符串转数组 | — | -| `/arraystring` | 数组拼接字符串 | — | -| `/daxiaoxie` | 英文大小写转换 | — | -| `/curlycue` | 花体英文转换 | — | -| `/htmlescape` | HTML转义 | — | -| `/jsonexcel` | JSON转Excel/CSV | — | -| `/phpformat` | PHP代码格式化 | — | -| `/ip` | IP归属地查询 | — | -| `/websocket` | WebSocket测试 | — | -| `/imghosting` | 聚合图床 | — | -| `/bot` | 搜索引擎蜘蛛大全 | `/bot/:id` | -| `/china_colors` | 中国传统色 | — | -| `/japan_colors` | 日本传统色 | — | -| `/meta` | Meta标签生成 | — | -| `/web_color` | 网页常用色彩 | — | -| `/safe_color` | WEB安全色 | — | -| `/url_batch` | 网址链接批量生成 | — | -| `/htaccess_nginx` | htaccess转nginx | — | -| `/refresh` | 在线定时刷新 | — | -| `/gif` | GIF动图制作 | — | -| `/dns` | DNS地址列表 | — | -| `/word` | Word快捷键 | — | -| `/excel` | Excel快捷键 | — | -| `/ppt` | PPT快捷键 | — | - -#### 🎮 休闲娱乐 - -| 路由 | 功能 | 详情路由 | -|------|------|---------| -| `/joke` | 笑话大全 | `/joke/:id` | -| `/eyesight` | 最强眼力 | — | -| `/mind_reader` | 读心术 | — | -| `/typewriter` | 速度打字机 | — | -| `/depression_quiz` | 抑郁症测试题 | — | -| `/anxiety_quiz` | 焦虑症测试题 | — | -| `/mdq_quiz` | 双相情感障碍筛查 | — | -| `/mania_quiz` | 狂躁症测试题 | — | -| `/moyu` | 摸鱼人 | — | - -### 其他页面路由 - -| 路由 | 功能 | -|------|------| -| `/` | 首页 | -| `/search/[:type]/[:word]` | 站内搜索 | -| `/about/privacy` | 隐私政策 | -| `/links` | 友情链接 | -| `/classify/study` | 教育学习分类 | -| `/classify/live` | 生活服务分类 | -| `/classify/calculate` | 实用计算分类 | -| `/classify/health` | 健康生活分类 | -| `/classify/webmaster` | 站长工具分类 | -| `/classify/recreation` | 休闲娱乐分类 | - ---- - -## 三、🔴 后端管理API(`admin` 模块) - -入口:`public/admin.php`,绑定到 `admin` 模块,关闭路由,采用 `/控制器/方法` 的传统URL格式。 - -### 系统管理 - -| 控制器 | 功能 | -|--------|------| -| `Index` | 后台首页/管理员登录/退出 | -| `Ajax` | 异步请求(语言包加载/文件上传/图标生成) | -| `Command` | 命令管理 | -| `Event` | 事件管理 | -| `Func` | 功能管理 | -| `Addon` | 插件管理(安装/配置/卸载) | -| `Ver` | 版本管理 | - -### 权限管理 - -| 控制器 | 功能 | -|--------|------| -| `auth/Admin` | 管理员管理(增删改查) | -| `auth/Group` | 角色组管理 | -| `auth/Rule` | 权限规则管理 | - -### 用户管理 - -| 控制器 | 功能 | -|--------|------| -| `user/User` | 前台用户管理 | -| `user/Group` | 用户组管理 | -| `user/Rule` | 用户规则管理 | - -### 数据管理控制器(70+个CRUD控制器) - -每个工具都有对应的后台管理控制器,提供标准的数据增删改查操作: - -| 控制器 | 对应数据 | 控制器 | 对应数据 | -|--------|---------|--------|---------| -| `Abbr` | 英文缩写 | `About` | 关于页面 | -| `Airport` | 机场代码 | `Aljj` | 鬼谷子 | -| `Barcode` | 条形码 | `Bmi` | BMI数据 | -| `Bot` | 搜索引擎蜘蛛 | `Bzql` | 百战奇略 | -| `Coitus` | 生肖配对 | `Couplet` | 对联 | -| `Cs` | 生活常识 | `Cy` | 成语 | -| `Drug` | 药品 | `Efs` | 歇后语 | -| `Food` | 食物相克 | `Gather` | 采集规则 | -| `Hanzi` | 汉字 | `Hdnj` | 黄帝内经 | -| `Herbal` | 中药材 | `Hfz` | 回文 | -| `Hot` | 热搜 | `Ic` | 身份证区号 | -| `Illness` | 疾病 | `Jfc` | 近反义词 | -| `Jgj` | 金刚经 | `Jieqi` | 节气 | -| `Joke` | 笑话 | `Jzdq` | 句子 | -| `Links` | 友情链接 | `Lscq` | 历史朝代 | -| `Lunyu` | 论语 | `Lyric` | 歌词 | -| `Ms` | 谜语 | `Mz` | 孟子 | -| `Nation` | 民族 | `Nick` | 网名 | -| `Perfect` | 待审核内容 | `Poet` | 诗人 | -| `Poetry` | 诗词 | `Port` | 港口 | -| `Qtrip` | 油耗 | `Riddle` | 谜语 | -| `Saying` | 谚语 | `Sbbf` | 生辰八字 | -| `Sgz` | 三十六计 | `Shool` | 绕口令 | -| `Sign` | 车标 | `Site` | 实用网站 | -| `Sj` | 史记 | `Spiders` | 蜘蛛规则 | -| `Ss` | 搜索 | `Story` | 故事 | -| `Surname` | 姓氏 | `Sx` | 生肖 | -| `Symbol` | 符号 | `Szbf` | 数字 | -| `Szj` | 三字经 | `Tisana` | 药茶 | -| `Tss` | 天书 | `Warring` | 战国策 | -| `Why` | 十万个为什么 | `Wine` | 酒方 | -| `Wisdom` | 名人名言 | `Wlyh` | 网络用语 | -| `Word` | 英文单词 | `Zc` | 组词 | -| `Zgjm` | 周公解梦 | `Zy` | 周易 | -| `Zz` | 庄子 | `Zztj` | 资治通鉴 | - ---- - -## 四、🟡 扩展库(`extend` 目录) - -| 类库 | 功能 | -|------|------| -| `fast/Auth` | FastAdmin权限认证 | -| `fast/Date` | 日期处理 | -| `fast/Form` | 表单构建 | -| `fast/Http` | HTTP请求封装 | -| `fast/Pinyin` | 拼音转换 | -| `fast/Random` | 随机数/UUID生成 | -| `fast/Rsa` | RSA加密解密 | -| `fast/Tree` | 树形结构处理 | -| `fast/Version` | 版本检测 | -| `Net/Ico` | ICO图标处理 | -| `Net/Ips` | IP地址处理 | - ---- - -## 五、📊 接口类型统计 - -| 类型 | 数量 | 说明 | -|------|------|------| -| 前端JSON API | 80+ | `/api/` 模块,返回JSON | -| 前端SSR页面 | 200+ | `index` 模块,返回HTML | -| 后端管理接口 | 70+ | `admin` 模块,CRUD管理 | -| 热榜采集接口 | 16 | 百度/微博/知乎/抖音等 | -| AI生成接口 | 9 | ChatGPT内容生成 | -| 计算器接口 | 15+ | 各种计算工具 | -| 搜索查询接口 | 30+ | 各类数据搜索 | - ---- - -## 六、🏗️ 架构总览 - -``` -┌─────────────────────────────────────────────┐ -│ 用户端 │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ PC网站 │ │ 小程序/APP│ │ 搜索引擎 │ │ -│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ -└───────┼─────────────┼─────────────┼──────────┘ - │ │ │ - ▼ ▼ ▼ -┌─────────────────────────────────────────────┐ -│ public/index.php │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ index │ │ api │ │ route │ │ -│ │ 模块 │ │ 模块 │ │ 路由 │ │ -│ │ (HTML) │ │ (JSON) │ │ 规则 │ │ -│ └────┬─────┘ └────┬─────┘ └──────────┘ │ -└───────┼─────────────┼────────────────────────┘ - │ │ - ▼ ▼ -┌─────────────────────────────────────────────┐ -│ common 模块 (公共层) │ -│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────────┐ │ -│ │ Auth │ │ Token│ │ Sms │ │ Upload │ │ -│ └──────┘ └──────┘ └──────┘ └──────────┘ │ -└──────────────────┬──────────────────────────┘ - │ - ┌──────────┼──────────┐ - ▼ ▼ ▼ -┌────────────┐ ┌────────┐ ┌──────────┐ -│ MySQL DB │ │ Redis │ │ 第三方API │ -│ (70+表) │ │ Cache │ │百度/ChatGPT│ -└────────────┘ └────────┘ └──────────┘ - -┌─────────────────────────────────────────────┐ -│ public/admin.php │ -│ admin 模块 (管理后台) │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ 权限管理 │ │ 数据CRUD │ │ 系统配置 │ │ -│ └──────────┘ └──────────┘ └──────────┘ │ -└─────────────────────────────────────────────┘ -``` - ---- - -## 七、🔑 核心特点 - -1. **工具箱定位** — 综合在线工具箱网站,提供 200+ 在线工具 -2. **AI内容生成** — 集成 ChatGPT API 自动生成绕口令、谜语、对联等内容 -3. **热榜聚合** — 采集 16 个平台的热搜数据(百度/微博/知乎/抖音等) -4. **FastAdmin权限体系** — 完整的RBAC权限管理(管理员/角色组/权限规则) -5. **多端支持** — 同时支持 PC网页渲染 和 JSON API(小程序/APP) -6. **SEO优化** — 全站TDK配置、百度/必应/神马推送、伪静态路由 -7. **数据采集** — QueryList爬虫框架 + 第三方API数据采集 diff --git a/iOS_macOS_Developer_Guide.md b/iOS_macOS_Developer_Guide.md index d180753f..fff9e78a 100644 --- a/iOS_macOS_Developer_Guide.md +++ b/iOS_macOS_Developer_Guide.md @@ -1111,6 +1111,14 @@ iOS/macOS 端这些检测不会执行(`isOhos` 为 false),无需关心。 > 鸿蒙端随后同步更新 `pubspec.yaml`(移除 `bitsdojo_window`,添加 `window_manager`), > 并删除 `packages/bitsdojo_window_windows/` 废弃目录。 +> **⚠️ 特殊案例:桌面端增强库(tray_manager / macos_window_utils / flutter_acrylic)** +> +> 这三个库仅 macOS/Windows/Linux 调用原生 API,鸿蒙端运行时 no-op(`pu.isDesktop` 守卫)。 +> 但 Dart 编译时**静态解析 import 链**:`app.dart` → `desktop_service_registry.dart` → 实现文件 → `package:tray_manager/...` +> +> 鸿蒙端 `pubspec.ohos.yaml` **必须声明这三个库**,否则编译报 `Target of URI doesn't exist`。 +> 运行时不会调用原生 API,无副作用。 + ### 5.6 常见问题 | 问题 | 原因 | 解决方案 | @@ -1125,6 +1133,7 @@ iOS/macOS 端这些检测不会执行(`isOhos` 为 false),无需关心。 | 编译报 `ohosName` 参数不存在 | 官方SDK的 HomeWidget 无此参数 | 使用 `dynamic` 调用,参见 §4.5.2 | | `pro_image_editor` 报 `CanvasStyleModel` 不存在 | 远程版本不含魔改内容 | 使用本地包 `path: packages/pro_image_editor`,参见 §2.8.1 | | `FilePicker.platform` 报错 | file_picker 11.x API 变更 | 使用 `FilePicker.pickFiles()`,参见 §2.8.2 | +| 鸿蒙端报 `Target of URI doesn't exist: package:tray_manager/...` | `pubspec.ohos.yaml` 缺桌面端库声明 | 在 `pubspec.ohos.yaml` 添加 `tray_manager`/`macos_window_utils`/`flutter_acrylic`,参见 §5.5 特殊案例 | --- diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 59b5df80..b320d9bb 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -117,7 +117,7 @@ AppGroupId group.apps.xy.xianyan.share - + CFBundleURLTypes @@ -128,6 +128,7 @@ CFBundleURLSchemes ShareMedia-apps.xy.xianyan + xianyan diff --git a/lib/app/app.dart b/lib/app/app.dart index 31334fb9..25c4507b 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -27,6 +27,10 @@ import '../core/services/device/quick_actions_service.dart'; import '../core/services/device/macos_platform_service.dart'; import '../core/services/device/windows_platform_service.dart'; import '../core/services/data/home_widget_service.dart'; +import '../core/services/desktop/desktop_service_registry.dart'; +import '../core/services/desktop/desktop_window_effect_service.dart'; +import '../features/desktop/desktop_tray_controller.dart'; +import '../features/desktop/macos_menu_bar_wrapper.dart'; import '../core/storage/database/app_database.dart'; import '../core/services/ui/status_bar_service.dart'; import '../core/services/accessibility_service.dart'; @@ -95,6 +99,12 @@ class _XianyanAppState extends ConsumerState Curve? _lastAnimateCurve; bool _pendingDataManagement = false; + /// 桌面端托盘控制器(仅桌面端初始化) + DesktopTrayController? _trayController; + + /// 上次同步到桌面端的主题状态(避免每次 build 重复调用) + bool? _lastDesktopDarkState; + @override void initState() { super.initState(); @@ -104,6 +114,60 @@ class _XianyanAppState extends ConsumerState _initDataManagementChannel(); _initHttpCache(); _initAccessibility(); + _initDesktopServices(); + } + + /// 初始化桌面端原生服务(托盘 + 窗口特效 + 自定义标题栏) + /// + /// 仅在桌面端(macOS/Windows/Linux)初始化: + /// 1. 注册桌面服务实现(DesktopServiceRegistry) + /// 2. 初始化窗口特效服务(毛玻璃/Acrylic/Mica) + /// 3. 初始化托盘控制器(图标 + 菜单 + 未读角标) + void _initDesktopServices() { + if (!pu.isDesktop) return; + + // 1. 注册桌面服务实现 + try { + DesktopServiceRegistry.init(); + } catch (e, st) { + Log.e('DesktopServiceRegistry.init 失败', e, st); + } + + // 2. 延迟初始化窗口特效 + 托盘控制器(等待 Provider 就绪) + WidgetsBinding.instance.addPostFrameCallback((_) { + try { + // 2a. 初始化窗口特效服务(毛玻璃/Acrylic/Mica) + _initWindowEffect(); + + // 2b. 初始化托盘控制器 + _trayController = DesktopTrayController(ref); + _trayController!.initialize().catchError((Object e, StackTrace st) { + Log.e('桌面端托盘控制器初始化失败', e, st); + }); + } catch (e, st) { + Log.e('桌面端服务初始化失败', e, st); + } + }); + } + + /// 初始化窗口特效服务并应用当前主题 + Future _initWindowEffect() async { + try { + final effectService = DesktopWindowEffectService.instance; + if (!effectService.isSupported) { + Log.w('窗口特效服务不支持当前平台'); + return; + } + + await effectService.initialize(); + + final isDark = ref.read(themeSettingsProvider).isDark; + await effectService.applyEffect(isDark: isDark); + + Log.i('窗口特效服务初始化完成: ${effectService.effectName}'); + } catch (e, st) { + Log.e('窗口特效服务初始化失败', e, st); + } } /// 初始化无障碍服务 @@ -215,6 +279,8 @@ class _XianyanAppState extends ConsumerState if (pu.isWeb) return; // 鸿蒙端暂不支持数据管理通道,跳过初始化避免 MissingPluginException if (pu.isOhos) return; + // macOS 端未实现数据管理通道,跳过避免 MissingPluginException + if (pu.isMacOS) return; _dataManagementChannel.setMethodCallHandler((call) async { switch (call.method) { @@ -339,6 +405,10 @@ class _XianyanAppState extends ConsumerState @override void dispose() { + // 销毁托盘控制器(异步,不阻塞 dispose) + _trayController?.dispose().catchError((Object e) { + Log.e('托盘控制器销毁失败', e); + }); disposeLifecycleGate(); disposeReadlaterRefreshController(); disposeFavoriteRefreshController(); @@ -383,6 +453,14 @@ class _XianyanAppState extends ConsumerState Brightness.dark; MacosPlatformService.syncTheme(isDark); WindowsPlatformService.syncTheme(isDark); + // 同步窗口特效 + DesktopWindowEffectService.instance + .applyEffect(isDark: isDark) + .catchError((Object e) { + Log.e('窗口特效主题同步失败(系统亮度变化)', e); + }); + // 同步托盘图标主题 + _trayController?.onThemeChanged(isDark); } } @@ -493,6 +571,21 @@ class _XianyanAppState extends ConsumerState MacosPlatformService.syncTheme(effectiveIsDark); WindowsPlatformService.syncTheme(effectiveIsDark); + // 同步窗口特效和托盘图标主题(仅在主题真正变化时触发,避免每次 build 重复调用) + if (_lastDesktopDarkState != effectiveIsDark) { + _lastDesktopDarkState = effectiveIsDark; + DesktopWindowEffectService.instance + .applyEffect(isDark: effectiveIsDark) + .catchError((Object e) { + Log.e('窗口特效主题同步失败', e); + }); + if (_trayController != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _trayController?.onThemeChanged(effectiveIsDark); + }); + } + } + return Directionality( textDirection: textDirection, child: _LocaleTransitionWrapper( @@ -596,6 +689,11 @@ class _XianyanAppState extends ConsumerState }, ); + // macOS 原生菜单栏包裹(非 macOS 平台直接返回 child) + final materialAppWithMenuBar = MacosMenuBarWrapper( + child: materialApp, + ); + final useGlass = PlatformCapabilities.supports( CapabilityKey.liquidGlass, ); @@ -622,9 +720,9 @@ class _XianyanAppState extends ConsumerState ), ), ), - child: materialApp, + child: materialAppWithMenuBar, ) - : materialApp; + : materialAppWithMenuBar; } final iconMode = generalSettings.iconMode; diff --git a/lib/app/layout/adaptive_nav_bar.dart b/lib/app/layout/adaptive_nav_bar.dart index 2758d484..6a064d1d 100644 --- a/lib/app/layout/adaptive_nav_bar.dart +++ b/lib/app/layout/adaptive_nav_bar.dart @@ -50,6 +50,9 @@ class AdaptiveNavBar extends ConsumerWidget { final animIntensity = settings.animationIntensity.durationMultiplier; final expressionStyle = settings.tabExpressionStyle; final characterId = settings.tabCharacterStyleId; + final splitState = ref.watch(splitViewProvider); + final isCollapsed = splitState.navBarCollapsed; + final isRight = splitState.navBarPosition == NavBarPosition.right; Widget buildTab(TabSpriteType type, int index, String label) { final isSelected = index == currentIndex; @@ -108,18 +111,29 @@ class AdaptiveNavBar extends ConsumerWidget { bounceMultiplier: expressionStyle.bounceMultiplier, ), ), - const SizedBox(height: 2), - Text( - label, - style: TextStyle( - fontSize: 10, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, - color: isSelected - ? (ext.isDark ? ext.textInverse : ext.accent) - : (ext.isDark - ? ext.textInverse.withValues(alpha: 0.38) - : const Color(0xFFAEAEB2)), - ), + // 折叠态隐藏文字标签 + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + child: isCollapsed + ? const SizedBox.shrink() + : Column( + children: [ + const SizedBox(height: 2), + Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected + ? (ext.isDark ? ext.textInverse : ext.accent) + : (ext.isDark + ? ext.textInverse.withValues(alpha: 0.38) + : const Color(0xFFAEAEB2)), + ), + ), + ], + ), ), ], ), @@ -127,11 +141,10 @@ class AdaptiveNavBar extends ConsumerWidget { ); } - final splitState = ref.watch(splitViewProvider); - final isRight = splitState.navBarPosition == NavBarPosition.right; - - return Container( - width: 72, + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + width: isCollapsed ? 48 : 72, decoration: BoxDecoration( color: ext.glassColor.withValues( alpha: ext.isDark @@ -146,7 +159,9 @@ class AdaptiveNavBar extends ConsumerWidget { sigmaY: GlassTokens.elevatedBlur * ext.glassBlurMultiplier, ), child: SafeArea( - right: isRight, + // 垂直导航栏:仅处理顶部和侧边安全区域 + // 底部不处理(Column 内有 Spacer 自适应,避免底部 SafeArea 挤压内容) + bottom: false, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -155,6 +170,9 @@ class AdaptiveNavBar extends ConsumerWidget { buildTab(TabSpriteType.discover, 1, '发现'), const SizedBox(height: AppSpacing.lg), buildTab(TabSpriteType.profile, 2, '我的'), + const Spacer(), + // 折叠/展开按钮 + _buildCollapseButton(ref, isCollapsed, ext), ], ), ), @@ -163,6 +181,24 @@ class AdaptiveNavBar extends ConsumerWidget { ); } + /// 折叠/展开导航栏按钮 + Widget _buildCollapseButton(WidgetRef ref, bool isCollapsed, AppThemeExtension ext) { + return GestureDetector( + onTap: () => ref.read(splitViewProvider.notifier).toggleNavBarCollapsed(), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Icon( + isCollapsed + ? CupertinoIcons.chevron_right + : CupertinoIcons.chevron_left, + size: 16, + color: ext.textSecondary, + ), + ), + ); + } + Widget _buildHorizontal(BuildContext context, WidgetRef ref) { final ext = AppTheme.ext(context); final settings = ref.watch(themeSettingsProvider); @@ -270,18 +306,14 @@ class AdaptiveNavBar extends ConsumerWidget { sigmaX: GlassTokens.elevatedBlur * ext.glassBlurMultiplier, sigmaY: GlassTokens.elevatedBlur * ext.glassBlurMultiplier, ), - child: SafeArea( - top: isTop, - bottom: !isTop, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - buildTab(TabSpriteType.home, 0, '闲言'), - buildTab(TabSpriteType.discover, 1, '发现'), - buildTab(TabSpriteType.profile, 2, '我的'), - if (isTop && pu.isDesktop) _buildDesktopWindowControls(ext), - ], - ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + buildTab(TabSpriteType.home, 0, '闲言'), + buildTab(TabSpriteType.discover, 1, '发现'), + buildTab(TabSpriteType.profile, 2, '我的'), + if (isTop && pu.isDesktop) _buildDesktopWindowControls(ext), + ], ), ), ), diff --git a/lib/app/layout/app_shell.dart b/lib/app/layout/app_shell.dart index fc8d53f7..cc991143 100644 --- a/lib/app/layout/app_shell.dart +++ b/lib/app/layout/app_shell.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 应用布局壳 // 创建时间: 2026-04-20 -// 更新时间: 2026-06-12 -// 作用: ShellRoute 布局壳,宽屏分屏 + 窄屏底部导航 -// 上次更新: 从 core/layout/ 迁移至 app/layout/,修复架构层级违规 +// 更新时间: 2026-06-18 +// 作用: ShellRoute 布局壳,宽屏工作台三栏 + 窄屏底部导航 +// 上次更新: 集成 WorkbenchLayout,右栏显示完整原始页面(非阉割版) // ============================================================ import 'package:badges/badges.dart' as badges; @@ -27,11 +27,11 @@ import '../../shared/widgets/containers/wallpaper_background.dart'; import '../../shared/widgets/containers/glass_bottom_nav_bar.dart'; import '../../shared/widgets/feedback/app_error_boundary.dart'; import '../../core/layout/adaptive_split_view.dart'; +import '../../core/layout/workbench/workbench_layout.dart'; +import '../../core/layout/workbench/right_panel_navigator.dart'; +import '../../features/desktop/desktop_window_title_bar.dart'; import 'adaptive_nav_bar.dart'; import 'overview_dashboard.dart'; -import '../../core/layout/right_panel_registry.dart'; -import '../../core/layout/triple_column_view.dart'; -import '../../features/profile/presentation/panels/profile_dashboard.dart'; class AppShell extends ConsumerStatefulWidget { const AppShell({super.key, required this.child}); @@ -51,10 +51,11 @@ class _AppShellState extends ConsumerState { final int currentIndex = widget.child.currentIndex; final screenWidth = MediaQuery.sizeOf(context).width; final splitState = ref.watch(splitViewProvider); - final isWidescreen = - screenWidth >= kSplitViewBreakpoint && splitState.splitViewEnabled; - // 鸿蒙端不再每次build打印日志,避免高频日志导致IDE卡顿 + // 工作台模式判断:宽屏 + 工作台开关开启 + 非鸿蒙端 + final isWorkbench = isWorkbenchWidth(screenWidth) && + splitState.workbenchEnabled && + !pu.isOhos; // 同步当前Tab索引到SplitViewProvider if (splitState.currentTab != currentIndex) { @@ -65,97 +66,49 @@ class _AppShellState extends ConsumerState { }); } - if (isWidescreen) { - return _buildWidescreenLayout(context, currentIndex); + // 构建主内容 + Widget content; + if (isWorkbench) { + content = _buildWorkbenchLayout(context, currentIndex); + } else { + content = _buildNarrowLayout(context, ext, currentIndex); } - return _buildNarrowLayout(context, ext, currentIndex); + // 桌面端在顶部添加自定义软件样式标题栏 + if (pu.isDesktop) { + return Column( + children: [ + const DesktopWindowTitleBar(), + Expanded(child: content), + ], + ); + } + + return content; } // ============================================================ - // 宽屏分屏布局 + // 工作台布局(宽屏三栏/双栏) // ============================================================ - Widget _buildWidescreenLayout(BuildContext context, int currentIndex) { - final splitState = ref.watch(splitViewProvider); - final navBarPosition = splitState.navBarPosition; - final isNavBarVertical = - navBarPosition == NavBarPosition.left || - navBarPosition == NavBarPosition.right; - final screenWidth = MediaQuery.sizeOf(context).width; - final isTripleColumn = - screenWidth >= kTripleColumnBreakpoint && - splitState.thirdPanelContent != null; - + Widget _buildWorkbenchLayout(BuildContext context, int currentIndex) { final Widget navBar = AdaptiveNavBar( currentIndex: currentIndex, onTabSelected: (index) => _onTabTap(context, index), ); - final Widget splitView = isTripleColumn - ? TripleColumnView( - leftPanel: RepaintBoundary( - child: AppErrorBoundary(label: '主页面', child: widget.child), - ), - centerPanel: _buildRightPanel(context), - rightPanel: _buildThirdPanel(context), - ) - : AdaptiveSplitView( - leftPanel: RepaintBoundary( - child: AppErrorBoundary(label: '主页面', child: widget.child), - ), - rightPanel: _buildRightPanel(context), - ); + // 右栏默认内容:统一显示概览仪表盘(合并三个Tab的仪表盘为一个) + const Widget defaultRightPanel = OverviewDashboard(); - /// 根据导航栏位置构建布局骨架 - Widget buildLayout() { - if (isNavBarVertical) { - final isLeft = navBarPosition == NavBarPosition.left; - return Scaffold( - body: Stack( - children: [ - const Positioned.fill(child: WallpaperBackground()), - Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (isLeft) navBar, - Expanded(child: splitView), - if (!isLeft) navBar, - ], - ), - ], - ), - ); - } - - final isTop = navBarPosition == NavBarPosition.top; - return Scaffold( - body: Stack( - children: [ - const Positioned.fill(child: WallpaperBackground()), - Column( - children: [ - if (isTop) navBar, - Expanded(child: splitView), - if (!isTop) navBar, - ], - ), - ], - ), - ); - } - - final Widget body = CelebrationOverlay( - child: PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, _) { - if (didPop) return; - }, - child: buildLayout(), + final Widget body = WorkbenchLayout( + navigationShell: RepaintBoundary( + child: AppErrorBoundary(label: '主页面', child: widget.child), ), + navBar: navBar, + defaultRightPanel: defaultRightPanel, ); - /// 桌面端添加键盘快捷键 (Ctrl+1/2/3 切换Tab, Ctrl+W 关闭面板) + // 桌面端添加键盘快捷键 if (pu.isDesktop) { return Shortcuts( shortcuts: { @@ -167,6 +120,12 @@ class _AppShellState extends ConsumerState { const _SwitchTabIntent(2), LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyW): const _ClosePanelIntent(), + // 工作台右栏返回快捷键 + LogicalKeySet(LogicalKeyboardKey.escape): + const _RightPanelBackIntent(), + LogicalKeySet( + LogicalKeyboardKey.control, LogicalKeyboardKey.arrowLeft): + const _RightPanelBackIntent(), }, child: Actions( actions: >{ @@ -174,6 +133,7 @@ class _AppShellState extends ConsumerState { onSwitch: (index) => _onTabTap(context, index), ), _ClosePanelIntent: _ClosePanelAction(ref: ref), + _RightPanelBackIntent: _RightPanelBackAction(ref: ref), }, child: Focus(child: body), ), @@ -183,41 +143,6 @@ class _AppShellState extends ConsumerState { return body; } - /// 构建右侧面板内容 - Widget _buildRightPanel(BuildContext context) { - final splitState = ref.watch(splitViewProvider); - final activePanel = splitState.activeRightPanel; - - if (activePanel == null) { - if (splitState.currentTab == 2) { - return const ProfileDashboard(); - } - return const OverviewDashboard(); - } - - return RightPanelRegistry.build( - activePanel, - context, - args: splitState.rightPanelArgs, - ); - } - - /// 构建第三栏面板内容(超宽屏三栏布局) - Widget _buildThirdPanel(BuildContext context) { - final splitState = ref.watch(splitViewProvider); - final thirdPanel = splitState.thirdPanelContent; - - if (thirdPanel == null) { - return const OverviewDashboard(); - } - - return RightPanelRegistry.build( - thirdPanel, - context, - args: splitState.thirdPanelArgs, - ); - } - // ============================================================ // 窄屏布局(原有逻辑) // ============================================================ @@ -373,12 +298,12 @@ class _AppShellState extends ConsumerState { ); } - /// 窄屏布局body:横屏未达分屏断点时居中限宽,竖屏保持原样 + /// 窄屏布局body:横屏未达工作台断点时居中限宽,竖屏保持原样 Widget _buildNarrowBody() { final size = MediaQuery.sizeOf(context); final isLandscapeNarrow = size.width > 600 && - size.width < kSplitViewBreakpoint && + size.width < kCompactBreakpoint && size.height < size.width; final content = Stack( @@ -473,7 +398,29 @@ class _ClosePanelAction extends Action<_ClosePanelIntent> { @override Object? invoke(_ClosePanelIntent intent) { - ref.read(splitViewProvider.notifier).clearActivePanel(); + // 关闭右栏当前页面:清空当前 Tab 的右栏栈 + final currentTab = ref.read(splitViewProvider).currentTab; + ref.read(rightPanelStackProvider.notifier).clear(currentTab); + return null; + } +} + +/// 右栏返回意图 +class _RightPanelBackIntent extends Intent { + const _RightPanelBackIntent(); +} + +/// 右栏返回动作 — pop 栈顶,逐页返回上一页 +class _RightPanelBackAction extends Action<_RightPanelBackIntent> { + _RightPanelBackAction({required this.ref}); + + final WidgetRef ref; + + @override + Object? invoke(_RightPanelBackIntent intent) { + // pop 右栏栈顶条目,逐页返回(符合 iOS 标准返回语义) + final currentTab = ref.read(splitViewProvider).currentTab; + ref.read(rightPanelStackProvider.notifier).pop(currentTab); return null; } } diff --git a/lib/app/layout/overview_dashboard.dart b/lib/app/layout/overview_dashboard.dart index 28a5a6f7..f56c0ffc 100644 --- a/lib/app/layout/overview_dashboard.dart +++ b/lib/app/layout/overview_dashboard.dart @@ -390,13 +390,14 @@ class OverviewDashboard extends ConsumerWidget { child: Row( children: stats.asMap().entries.map((entry) { final stat = entry.value; - return AnimationConfiguration.staggeredList( - position: entry.key, - duration: const Duration(milliseconds: 375), - child: SlideAnimation( - verticalOffset: 50.0, - child: FadeInAnimation( - child: Expanded( + // Expanded 必须是 Row 的直接子节点,因此放在动画包裹链的最外层 + return Expanded( + child: AnimationConfiguration.staggeredList( + position: entry.key, + duration: const Duration(milliseconds: 375), + child: SlideAnimation( + verticalOffset: 50.0, + child: FadeInAnimation( child: GlassContainer( padding: const EdgeInsets.all(AppSpacing.sm), margin: const EdgeInsets.symmetric( diff --git a/lib/core/layout/adaptive_split_view.dart b/lib/core/layout/adaptive_split_view.dart index cd186ec5..0b483f17 100644 --- a/lib/core/layout/adaptive_split_view.dart +++ b/lib/core/layout/adaptive_split_view.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 自适应分屏组件 /// 创建时间: 2026-05-29 -/// 更新时间: 2026-05-29 +/// 更新时间: 2026-06-18 /// 作用: 宽屏时左右分屏布局,支持可拖拽分割线、手势隔离、动画过渡 -/// 上次更新: 完全移除AnimationController,使用flutter_animate target声明式控制动画方向 +/// 上次更新: 断点常量对齐设计规则 768/1024/1280,支持工作台三栏模式 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -14,8 +14,30 @@ import '../providers/split_view_provider.dart'; import 'panel_cache.dart'; import 'split_divider.dart'; -/// 分屏断点:宽度 >= 900px 进入分屏模式 -const double kSplitViewBreakpoint = 900.0; +// ============================================================ +// 响应式断点常量 — 对齐 .trae/rules/design-rules.md +// ============================================================ + +/// 紧凑模式断点:宽度 >= 768px 从单栏切换到双栏 +const double kCompactBreakpoint = 768.0; + +/// 中等模式断点:宽度 >= 1024px 从双栏切换到三栏 +const double kMediumBreakpoint = 1024.0; + +/// 展开模式断点:宽度 >= 1280px 三栏完整显示(中栏更宽) +const double kExpandedBreakpoint = 1280.0; + +/// @deprecated 旧断点,保留向后兼容,实际指向 kCompactBreakpoint +const double kSplitViewBreakpoint = kCompactBreakpoint; + +/// 判断当前宽度是否处于工作台模式(双栏或三栏) +bool isWorkbenchWidth(double width) => width >= kCompactBreakpoint; + +/// 判断当前宽度是否处于三栏模式 +bool isTripleColumnWidth(double width) => width >= kMediumBreakpoint; + +/// 判断当前宽度是否处于三栏完整模式(中栏更宽) +bool isExpandedTripleColumnWidth(double width) => width >= kExpandedBreakpoint; class AdaptiveSplitView extends ConsumerStatefulWidget { const AdaptiveSplitView({ diff --git a/lib/core/layout/panel_bookmark.dart b/lib/core/layout/panel_bookmark.dart deleted file mode 100644 index 688a60da..00000000 --- a/lib/core/layout/panel_bookmark.dart +++ /dev/null @@ -1,90 +0,0 @@ -/// ============================================================ -/// 闲言APP — 面板书签功能 -/// 创建时间: 2026-05-29 -/// 更新时间: 2026-05-29 -/// 作用: 收藏常用面板组合,一键切换 -/// 上次更新: 初始创建 -/// ============================================================ - -import 'dart:convert'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../storage/kv_storage.dart'; - -/// 面板书签数据模型 -class PanelBookmark { - const PanelBookmark({ - required this.name, - required this.tabIndex, - required this.panelId, - this.panelArgs, - }); - - final String name; - final int tabIndex; - final String panelId; - final Map? panelArgs; - - /// 序列化为JSON - Map toJson() => { - 'name': name, - 'tabIndex': tabIndex, - 'panelId': panelId, - 'panelArgs': panelArgs, - }; - - /// 从JSON反序列化 - factory PanelBookmark.fromJson(Map json) => PanelBookmark( - name: json['name'] as String, - tabIndex: json['tabIndex'] as int, - panelId: json['panelId'] as String, - panelArgs: json['panelArgs'] as Map?, - ); -} - -/// 面板书签状态管理Notifier -class PanelBookmarkNotifier extends Notifier> { - static const _key = 'panel_bookmarks'; - - @override - List build() { - final raw = KvStorage.getString(_key); - if (raw == null) return []; - try { - final list = jsonDecode(raw) as List; - return list - .map((e) => PanelBookmark.fromJson(e as Map)) - .toList(); - } catch (_) { - return []; - } - } - - /// 添加书签 - void addBookmark(PanelBookmark bookmark) { - state = [...state, bookmark]; - _save(); - } - - /// 移除书签 - void removeBookmark(int index) { - if (index < 0 || index >= state.length) return; - state = [...state]..removeAt(index); - _save(); - } - - /// 持久化到KvStorage - void _save() { - KvStorage.setString( - _key, - jsonEncode(state.map((e) => e.toJson()).toList()), - ); - } -} - -/// 面板书签Provider -final panelBookmarkProvider = - NotifierProvider>( - PanelBookmarkNotifier.new, -); diff --git a/lib/core/layout/right_panel_registry.dart b/lib/core/layout/right_panel_registry.dart deleted file mode 100644 index 8f0ad26b..00000000 --- a/lib/core/layout/right_panel_registry.dart +++ /dev/null @@ -1,67 +0,0 @@ -/// ============================================================ -/// 闲言APP — 右侧面板注册表 -/// 创建时间: 2026-05-29 -/// 更新时间: 2026-05-29 -/// 作用: 管理右侧面板的注册与构建,各Tab页面通过注册表提供面板内容 -/// 上次更新: 移除dartx依赖,使用Dart内置安全集合访问 -/// ============================================================ - -import 'package:flutter/widgets.dart'; - -typedef RightPanelBuilder = Widget Function( - BuildContext context, - Map? args, -); - -class RightPanelRegistry { - RightPanelRegistry._(); - - static final Map _builders = {}; - - static void register(String panelId, RightPanelBuilder builder) { - _builders[panelId] = builder; - } - - static void registerAll(Map entries) { - _builders.addAll(entries); - } - - static Widget build(String panelId, BuildContext context, {Map? args}) { - final builder = _builders[panelId]; - if (builder == null) { - return _buildPlaceholder(context, panelId); - } - return builder(context, args); - } - - static bool hasPanel(String panelId) => _builders.containsKey(panelId); - - static List get registeredIds => _builders.keys.toList(); - - static String? getPanelIdAtIndex(int index) { - final ids = registeredIds; - return (index >= 0 && index < ids.length) ? ids[index] : null; - } - - static RightPanelBuilder? getBuilderAtIndex(int index) { - final id = getPanelIdAtIndex(index); - if (id == null) return null; - return _builders[id]; - } - - static Widget _buildPlaceholder(BuildContext context, String panelId) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('🚧', style: TextStyle(fontSize: 48)), - const SizedBox(height: 16), - Text( - '面板 "$panelId" 尚未注册', - style: const TextStyle(fontSize: 14), - ), - ], - ), - ); - } -} diff --git a/lib/core/layout/split_view_navigation_mixin.dart b/lib/core/layout/split_view_navigation_mixin.dart index a77eb700..fa364088 100644 --- a/lib/core/layout/split_view_navigation_mixin.dart +++ b/lib/core/layout/split_view_navigation_mixin.dart @@ -1,20 +1,18 @@ /// ============================================================ /// 闲言APP — 分屏导航 Mixin /// 创建时间: 2026-06-10 -/// 更新时间: 2026-06-10 -/// 作用: 提供宽屏面板/窄屏路由双模式导航逻辑 -/// 上次更新: 从 profile_page.dart 抽取为 Mixin +/// 更新时间: 2026-06-18 +/// 作用: 统一走 context.appPush,由 app_nav_extension 自动处理工作台模式 +/// 上次更新: 移除旧面板ID机制,统一使用嵌套Navigator右栏(显示完整原始页面) /// ============================================================ -import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../providers/split_view_provider.dart'; -import 'adaptive_split_view.dart'; import '../router/app_nav_extension.dart'; -/// 分屏导航 Mixin — 宽屏面板 / 窄屏路由双模式导航 +/// 分屏导航 Mixin — 统一导航入口 /// +/// 工作台模式判断已下沉到 `context.appPush`,无需在调用方判断 /// 使用方式: /// ```dart /// class _MyPageState extends ConsumerState @@ -22,32 +20,16 @@ import '../router/app_nav_extension.dart'; /// ``` mixin SplitViewNavigationMixin on ConsumerState { - /// 判断当前是否为宽屏分屏模式 - bool isWidescreen() { - final screenWidth = MediaQuery.sizeOf(context).width; - final splitState = ref.read(splitViewProvider); - return screenWidth >= kSplitViewBreakpoint && splitState.splitViewEnabled; - } - - /// 在宽屏模式下打开右侧面板 - void openSettingsPanel(String panelId, {Map? args}) { - if (isWidescreen()) { - ref - .read(splitViewProvider.notifier) - .setProfileRightPanel(panelId, args: args); - } - } - - /// 智能导航:宽屏打开面板,窄屏跳转路由 + /// 智能导航:统一走 context.appPush + /// + /// 工作台模式(宽屏+开启)下自动 push 到右栏嵌套 Navigator + /// 窄屏或全屏路由走 GoRouter rootNavigator push void navigateOrPanel( String route, String panelId, { Map? args, + String? title, }) { - if (isWidescreen()) { - openSettingsPanel(panelId, args: args); - } else { - context.appPush(route); - } + context.appPush(route, extra: args, title: title); } } diff --git a/lib/core/layout/triple_column_view.dart b/lib/core/layout/triple_column_view.dart deleted file mode 100644 index 306cbcae..00000000 --- a/lib/core/layout/triple_column_view.dart +++ /dev/null @@ -1,73 +0,0 @@ -/// ============================================================ -/// 闲言APP — 三栏布局组件 -/// 创建时间: 2026-05-29 -/// 更新时间: 2026-05-29 -/// 作用: 超宽屏(>=1400px)三栏布局:左侧列表+中间详情+右侧辅助面板 -/// 上次更新: 初始创建 -/// ============================================================ - -import 'package:flutter/cupertino.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'split_divider.dart'; - -class TripleColumnView extends ConsumerStatefulWidget { - const TripleColumnView({ - required this.leftPanel, - required this.centerPanel, - required this.rightPanel, - super.key, - }); - - final Widget leftPanel; - final Widget centerPanel; - final Widget rightPanel; - - @override - ConsumerState createState() => _TripleColumnViewState(); -} - -class _TripleColumnViewState extends ConsumerState { - double _leftRatio = 0.25; - double _centerRatio = 0.40; - - @override - Widget build(BuildContext context) { - final width = MediaQuery.of(context).size.width; - final leftWidth = width * _leftRatio; - final centerWidth = width * _centerRatio; - - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox(width: leftWidth, child: widget.leftPanel), - SplitDivider( - currentPosition: _leftRatio, - onPositionChanged: (newRatio) { - setState(() { - final delta = newRatio - _leftRatio; - _leftRatio = newRatio.clamp(0.15, 0.35); - _centerRatio = (_centerRatio - delta).clamp(0.25, 0.55); - }); - }, - minPosition: 0.15, - maxPosition: 0.35, - ), - SizedBox(width: centerWidth, child: widget.centerPanel), - SplitDivider( - currentPosition: _leftRatio + _centerRatio, - onPositionChanged: (newRatio) { - setState(() { - final combinedRatio = newRatio; - _centerRatio = - (combinedRatio - _leftRatio).clamp(0.25, 0.55); - }); - }, - minPosition: _leftRatio + 0.25, - maxPosition: _leftRatio + 0.55, - ), - Expanded(child: widget.rightPanel), - ], - ); - } -} diff --git a/lib/core/layout/workbench/right_panel_navigator.dart b/lib/core/layout/workbench/right_panel_navigator.dart new file mode 100644 index 00000000..14481be6 --- /dev/null +++ b/lib/core/layout/workbench/right_panel_navigator.dart @@ -0,0 +1,240 @@ +/// ============================================================ +/// 闲言APP — 右栏页面栈管理器 +/// 创建时间: 2026-06-18 +/// 更新时间: 2026-06-19 +/// 作用: 工作台模式下右栏的嵌套 Navigator,显示完整原始页面(非阉割版) +/// 上次更新: pop 方法改为 canPop 检查,与 canPop 判断保持一致,避免根级页面被意外弹空 +/// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/router/route_def.dart'; +import 'package:xianyan/core/router/route_registry.dart'; + +/// 右栏页面栈条目 +@immutable +class RightPanelEntry { + const RightPanelEntry({ + required this.route, + required this.title, + this.extra, + this.builder, + }); + + /// 路由路径 + final String route; + + /// 页面标题(显示在右栏顶栏) + final String title; + + /// 传递给页面的参数 + final Object? extra; + + /// 页面构建器(优先使用,若为空则从 RouteDef 查找) + final WidgetBuilder? builder; +} + +/// 右栏页面栈状态 +@immutable +class RightPanelStackState { + const RightPanelStackState({ + this.entries = const [], + this.currentTab = 0, + }); + + /// 页面栈(栈底在前,栈顶在后) + final List entries; + + /// 当前所属 Tab + final int currentTab; + + /// 栈深 + int get depth => entries.length; + + /// 栈顶条目 + RightPanelEntry? get top => entries.isEmpty ? null : entries.last; + + /// 是否可以返回 + bool get canPop => entries.length > 1; + + RightPanelStackState copyWith({ + List? entries, + int? currentTab, + }) { + return RightPanelStackState( + entries: entries ?? this.entries, + currentTab: currentTab ?? this.currentTab, + ); + } +} + +/// 右栏页面栈管理器(每 Tab 独立栈) +class RightPanelStackNotifier + extends Notifier> { + @override + Map build() { + // 3 个 Tab 各自独立的栈状态 + return { + 0: const RightPanelStackState(currentTab: 0), + 1: const RightPanelStackState(currentTab: 1), + 2: const RightPanelStackState(currentTab: 2), + }; + } + + /// 获取指定 Tab 的栈状态 + RightPanelStackState getStack(int tabIndex) { + return state[tabIndex] ?? RightPanelStackState(currentTab: tabIndex); + } + + /// 获取当前 Tab 的栈状态 + RightPanelStackState getCurrentStack(int currentTab) { + return getStack(currentTab); + } + + /// push 一个新页面到指定 Tab 的右栏栈 + void push(int tabIndex, RightPanelEntry entry) { + final currentStack = getStack(tabIndex); + final newEntries = [...currentStack.entries, entry]; + state = { + ...state, + tabIndex: currentStack.copyWith(entries: newEntries), + }; + } + + /// 从指定 Tab 的右栏栈 pop 一个页面 + /// 与 canPop 判断保持一致:栈深 <= 1 时不执行 pop,避免根级页面被意外弹空 + void pop(int tabIndex) { + final currentStack = getStack(tabIndex); + if (!currentStack.canPop) return; + final newEntries = [...currentStack.entries]..removeLast(); + state = { + ...state, + tabIndex: currentStack.copyWith(entries: newEntries), + }; + } + + /// 清空指定 Tab 的右栏栈 + void clear(int tabIndex) { + final currentStack = getStack(tabIndex); + state = { + ...state, + tabIndex: currentStack.copyWith(entries: []), + }; + } + + /// 重置指定 Tab 的栈为单个页面 + void resetTo(int tabIndex, RightPanelEntry entry) { + final currentStack = getStack(tabIndex); + state = { + ...state, + tabIndex: currentStack.copyWith(entries: [entry]), + }; + } +} + +/// 右栏页面栈 Provider +final rightPanelStackProvider = + NotifierProvider>( + RightPanelStackNotifier.new, +); + +/// 获取指定 Tab 当前右栏栈顶路由的 Provider(供一级页面判断选中状态) +/// +/// 使用方式: +/// ```dart +/// final activeRoute = ref.watch(activeRightPanelRouteProvider(currentTab)); +/// // 如果 activeRoute == '/favorites' 则高亮收藏按钮 +/// ``` +final activeRightPanelRouteProvider = Provider.family((ref, tab) { + final stackMap = ref.watch(rightPanelStackProvider); + final stack = stackMap[tab]; + return stack?.top?.route; +}); + +// ============================================================ +// 右栏页面构建辅助 +// ============================================================ + +/// 根据路由路径构建完整原始页面 +Widget? buildPageFromRoute(String route, {Object? extra}) { + final def = findRouteDef(route); + if (def == null) return null; + + // 解析查询参数和路径参数 + final parsed = _parseRouteParams(route, def.path); + + // 优先使用 builder + if (def.builder != null) { + final ctx = RouteContext( + path: route, + pathParams: parsed.pathParams, + queryParams: parsed.queryParams, + extra: extra, + ); + return def.builder!(ctx); + } + + // 其次使用 page + if (def.page != null) { + return def.page!(); + } + + return null; +} + +/// 解析路由路径中的查询参数和路径参数 +_ParsedRoute _parseRouteParams(String route, String pattern) { + // 剥离查询参数 + final queryIndex = route.indexOf('?'); + final cleanPath = queryIndex >= 0 ? route.substring(0, queryIndex) : route; + final queryString = queryIndex >= 0 ? route.substring(queryIndex + 1) : ''; + + // 解析查询参数 + final queryParams = {}; + if (queryString.isNotEmpty) { + for (final pair in queryString.split('&')) { + final eq = pair.indexOf('='); + if (eq >= 0) { + queryParams[Uri.decodeQueryComponent(pair.substring(0, eq))] = + Uri.decodeQueryComponent(pair.substring(eq + 1)); + } else { + queryParams[Uri.decodeQueryComponent(pair)] = ''; + } + } + } + + // 解析路径参数(如 /chat_flow/:id → /chat_flow/abc) + final pathParams = {}; + final patternSegs = pattern.split('/'); + final pathSegs = cleanPath.split('/'); + if (patternSegs.length == pathSegs.length) { + for (var i = 0; i < patternSegs.length; i++) { + final p = patternSegs[i]; + if (p.startsWith(':')) { + pathParams[p.substring(1)] = pathSegs[i]; + } + } + } + + return _ParsedRoute(pathParams: pathParams, queryParams: queryParams); +} + +class _ParsedRoute { + const _ParsedRoute({required this.pathParams, required this.queryParams}); + final Map pathParams; + final Map queryParams; +} + +/// 从路由路径推断页面标题 +String inferRouteTitle(String route) { + final def = findRouteDef(route); + if (def != null && def.name.isNotEmpty) { + // 将 name 转为可读标题 + final parts = def.name.split('-'); + return parts + .map((p) => p.isEmpty ? '' : '${p[0].toUpperCase()}${p.substring(1)}') + .join(' '); + } + return route; +} diff --git a/lib/core/layout/workbench/workbench_layout.dart b/lib/core/layout/workbench/workbench_layout.dart new file mode 100644 index 00000000..e5640fa4 --- /dev/null +++ b/lib/core/layout/workbench/workbench_layout.dart @@ -0,0 +1,419 @@ +/// ============================================================ +/// 闲言APP — PC 工作台布局组件 +/// 创建时间: 2026-06-18 +/// 更新时间: 2026-06-19 +/// 作用: 微信PC式三栏工作台布局(导航栏+中栏列表+右栏详情) +/// 上次更新: 右栏返回双按钮(pop+clear);拖拽clamp合并取min;双栏注释修正;构建失败移除无效条目改用canPop判断 +/// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../providers/split_view_provider.dart'; +import '../../storage/kv_storage.dart'; +import '../adaptive_split_view.dart'; +import 'right_panel_navigator.dart'; + +/// 工作台布局组件 +/// +/// 布局结构: +/// ┌──────┬────────────┬──────────────────────────┐ +/// │ 导航 │ 内容列表 │ 内容详情 │ +/// │ 72px │ 380px可调 │ 自适应 │ +/// └──────┴────────────┴──────────────────────────┘ +class WorkbenchLayout extends ConsumerStatefulWidget { + const WorkbenchLayout({ + required this.navigationShell, + required this.navBar, + required this.defaultRightPanel, + super.key, + }); + + /// StatefulNavigationShell,提供 3 个 Tab 分支 + final Widget navigationShell; + + /// 导航栏组件(AdaptiveNavBar) + final Widget navBar; + + /// 右栏默认内容(未选中二级页面时显示,通常为 OverviewDashboard) + final Widget defaultRightPanel; + + @override + ConsumerState createState() => _WorkbenchLayoutState(); +} + +class _WorkbenchLayoutState extends ConsumerState { + double? _middleWidth; + bool _isDragging = false; + + static const double _minMiddleWidth = 320.0; + static const double _maxMiddleWidth = 600.0; + static const double _defaultMiddleWidth = 380.0; + static const double _dividerWidth = 6.0; + + @override + void initState() { + super.initState(); + _restoreMiddleWidth(); + } + + /// 从 KvStorage 恢复中栏宽度 + /// + /// 延迟到首帧后执行,避免在 initState 中触发 setState。 + /// 读取失败或未设置时使用默认值 _defaultMiddleWidth。 + void _restoreMiddleWidth() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final saved = KvStorage.getDouble( + StorageKeys.nsSettingsWorkbenchMiddleWidth, + ); + if (saved != null && saved >= _minMiddleWidth && saved <= _maxMiddleWidth) { + setState(() => _middleWidth = saved); + } + }); + } + + // ============================================================ + // 拖拽分割条 + // ============================================================ + + void _onDragStart(DragStartDetails details) { + setState(() => _isDragging = true); + } + + void _onDragUpdate(DragUpdateDetails details, double screenWidth) { + final navWidth = _navBarWidth(); + final newWidth = (_middleWidth ?? _defaultMiddleWidth) + details.delta.dx; + // 综合屏幕可用宽度与硬编码上限,取较小值作为有效最大宽度 + final screenMax = screenWidth - navWidth - 400 - _dividerWidth; + final effectiveMax = screenMax < _maxMiddleWidth ? screenMax : _maxMiddleWidth; + // 保护下限:极端小屏时 screenMax 可能 < _minMiddleWidth,避免 clamp 抛 ArgumentError + final safeMax = effectiveMax < _minMiddleWidth ? _minMiddleWidth : effectiveMax; + final finalWidth = newWidth.clamp(_minMiddleWidth, safeMax); + setState(() => _middleWidth = finalWidth); + } + + void _onDragEnd(DragEndDetails details) { + setState(() => _isDragging = false); + // 持久化中栏宽度 + _persistMiddleWidth(); + } + + /// 持久化中栏宽度到 KvStorage + /// + /// 拖拽结束时调用,异步写入,失败时静默忽略(下次启动使用默认值)。 + void _persistMiddleWidth() { + final width = _middleWidth ?? _defaultMiddleWidth; + KvStorage.setDouble( + StorageKeys.nsSettingsWorkbenchMiddleWidth, + width, + ); + } + + double _navBarWidth() { + final splitState = ref.read(splitViewProvider); + final isVertical = splitState.navBarPosition == NavBarPosition.left || + splitState.navBarPosition == NavBarPosition.right; + if (!isVertical) return 0.0; + // 折叠态固定 48px,展开态使用持久化的宽度 + return splitState.navBarCollapsed ? 48.0 : splitState.navBarWidth; + } + + // ============================================================ + // 构建 + // ============================================================ + + @override + Widget build(BuildContext context) { + final splitState = ref.watch(splitViewProvider); + final screenWidth = MediaQuery.sizeOf(context).width; + final navBarPosition = splitState.navBarPosition; + final currentTab = splitState.currentTab; + + // 判断布局模式 + final isTripleColumn = isTripleColumnWidth(screenWidth); + final isWorkbench = isWorkbenchWidth(screenWidth) && + splitState.workbenchEnabled; + + if (!isWorkbench) { + // 非工作台模式:返回 null,由 AppShell 走窄屏布局 + return widget.navigationShell; + } + + // 构建三栏内容 + final Widget middlePanel = _buildMiddlePanel(); + final Widget rightPanel = _buildRightPanel(currentTab); + final Widget divider = _buildDivider(screenWidth); + + // 构建内容区(中栏 + 分割条 + 右栏) + Widget contentArea; + if (isTripleColumn) { + contentArea = Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: _middleWidth ?? _defaultMiddleWidth, + child: middlePanel, + ), + divider, + Expanded(child: rightPanel), + ], + ); + } else { + // 双栏模式:中栏紧凑显示(宽度*0.75)+ 分割条 + 右栏 + contentArea = Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: (_middleWidth ?? _defaultMiddleWidth) * 0.75, + child: middlePanel, + ), + divider, + Expanded(child: rightPanel), + ], + ); + } + + // 根据导航栏位置组合布局 + return _assembleLayout( + navBarPosition: navBarPosition, + navBar: widget.navBar, + contentArea: contentArea, + ); + } + + /// 组装导航栏 + 内容区 + Widget _assembleLayout({ + required NavBarPosition navBarPosition, + required Widget navBar, + required Widget contentArea, + }) { + final isVertical = navBarPosition == NavBarPosition.left || + navBarPosition == NavBarPosition.right; + final isStart = navBarPosition == NavBarPosition.left || + navBarPosition == NavBarPosition.top; + + if (isVertical) { + // 垂直导航栏(左/右):动态宽度(支持折叠态 48px / 展开态可调) + final navWidth = _navBarWidth(); + return Scaffold( + body: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (isStart) + SizedBox(width: navWidth, child: navBar), + Expanded(child: contentArea), + if (!isStart) + SizedBox(width: navWidth, child: navBar), + ], + ), + ); + } + + // 水平导航栏(顶/底):固定高度 52px + return Scaffold( + body: Column( + children: [ + if (isStart) + SizedBox(height: 52, child: navBar), + Expanded(child: contentArea), + if (!isStart) + SizedBox(height: 52, child: navBar), + ], + ), + ); + } + + /// 构建中栏(一级页面列表) + Widget _buildMiddlePanel() { + // 中栏显示当前 Tab 的一级页面(HomePage/DiscoverPage/ProfilePage) + // 通过 navigationShell 提供 + return Container( + color: Theme.of(context).colorScheme.surfaceContainerLowest, + child: widget.navigationShell, + ); + } + + /// 构建右栏(二级/三级页面详情) + Widget _buildRightPanel(int currentTab) { + // 关键:watch 状态本身(Map),而非 notifier + // 这样状态变化时才会触发重建 + final stackMap = ref.watch(rightPanelStackProvider); + final stackState = stackMap[currentTab] ?? + RightPanelStackState(currentTab: currentTab); + final entries = stackState.entries; + + // 栈为空:显示默认面板(概览仪表盘) + if (entries.isEmpty) { + return widget.defaultRightPanel; + } + + // 栈非空:显示栈顶页面(完整原始页面) + final topEntry = entries.last; + final pageWidget = topEntry.builder != null + ? topEntry.builder!(context) + : buildPageFromRoute(topEntry.route, extra: topEntry.extra); + + if (pageWidget == null) { + // 页面构建失败:移除无效条目,避免脏栈残留 + // canPop 时 pop 栈顶(保留下层有效条目);栈深<=1 时 clear(无效条目即根级,清空回仪表盘) + // 使用 PostFrameCallback 延迟到 build 结束后修改状态,防止 build-during-build + WidgetsBinding.instance.addPostFrameCallback((_) { + final notifier = ref.read(rightPanelStackProvider.notifier); + if (notifier.getStack(currentTab).canPop) { + notifier.pop(currentTab); + } else { + notifier.clear(currentTab); + } + }); + return widget.defaultRightPanel; + } + + return Column( + children: [ + // 右栏顶栏(返回按钮 + 标题) + _buildRightTopBar(stackState), + // 右栏内容(完整原始页面) + Expanded( + child: KeyedSubtree( + key: ValueKey('${topEntry.route}_${entries.length}'), + child: pageWidget, + ), + ), + ], + ); + } + + /// 构建右栏顶栏 + Widget _buildRightTopBar(RightPanelStackState stackState) { + final ext = Theme.of(context); + final canPop = stackState.canPop; + final title = stackState.top?.title ?? ''; + + return Container( + height: 48, + decoration: BoxDecoration( + color: ext.colorScheme.surfaceContainerLowest.withValues(alpha: 0.72), + border: Border( + bottom: BorderSide( + color: ext.colorScheme.outlineVariant.withValues(alpha: 0.3), + ), + ), + ), + child: Row( + children: [ + // 返回按钮组(栈深>1 时显示) + if (canPop) ...[ + // 返回上一页(标准 pop) + CupertinoButton( + padding: EdgeInsets.zero, + minimumSize: const Size(40, 40), + onPressed: _onRightPanelPop, + child: Icon( + CupertinoIcons.back, + color: ext.colorScheme.primary, + size: 22, + ), + ), + // 回仪表盘(清空栈,快捷回根) + CupertinoButton( + padding: EdgeInsets.zero, + minimumSize: const Size(40, 40), + onPressed: _onRightPanelBackToRoot, + child: Icon( + CupertinoIcons.house_fill, + color: ext.colorScheme.primary, + size: 20, + ), + ), + ] else + const SizedBox(width: 40), + // 标题 + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: ext.colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + // 右侧操作按钮(预留) + const SizedBox(width: 40), + ], + ), + ); + } + + /// 构建分割条 + Widget _buildDivider(double screenWidth) { + return MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + child: GestureDetector( + onHorizontalDragStart: _onDragStart, + onHorizontalDragUpdate: (details) => _onDragUpdate(details, screenWidth), + onHorizontalDragEnd: _onDragEnd, + child: Container( + width: _dividerWidth, + color: _isDragging + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.5) + : Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.2), + ), + ), + ); + } + + /// 右栏返回上一页 — pop 栈顶条目,逐页返回 + /// 符合 iOS UINavigationController 标准返回语义 + void _onRightPanelPop() { + final currentTab = ref.read(splitViewProvider).currentTab; + ref.read(rightPanelStackProvider.notifier).pop(currentTab); + } + + /// 右栏回仪表盘 — 清空栈,直接回到默认仪表盘 + /// 用于栈深较大时的快捷返回根页面 + void _onRightPanelBackToRoot() { + final currentTab = ref.read(splitViewProvider).currentTab; + ref.read(rightPanelStackProvider.notifier).clear(currentTab); + } +} + +// ============================================================ +// 工作台快捷键 Intent +// ============================================================ + +/// 右栏返回 Intent +class RightPanelBackIntent extends Intent { + const RightPanelBackIntent(); +} + +/// 关闭右栏当前页面 Intent +class CloseRightPanelIntent extends Intent { + const CloseRightPanelIntent(); +} + +/// 工作台快捷键映射 +Map workbenchShortcuts({ + bool isMacos = false, + bool isDesktop = false, +}) { + if (!isDesktop) return {}; + + final ctrl = isMacos + ? LogicalKeyboardKey.meta + : LogicalKeyboardKey.control; + + return { + // Ctrl/Cmd + ← 右栏后退 + LogicalKeySet(ctrl, LogicalKeyboardKey.arrowLeft): + const RightPanelBackIntent(), + // Esc 右栏返回 + LogicalKeySet(LogicalKeyboardKey.escape): + const RightPanelBackIntent(), + }; +} diff --git a/lib/core/models/user_model.dart b/lib/core/models/user_model.dart index 8a58dad9..e723f5f6 100644 --- a/lib/core/models/user_model.dart +++ b/lib/core/models/user_model.dart @@ -1,11 +1,13 @@ /// ============================================================ /// 闲言APP — 用户数据模型(核心层) /// 创建时间: 2026-06-12 -/// 更新时间: 2026-06-12 +/// 更新时间: 2026-06-19 /// 作用: 用户信息数据模型,对应后端 tool_user 表 -/// 上次更新: 从 features/auth/models/ 提取至 core/models/,修复架构分层违规 +/// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0 /// ============================================================ +import 'package:xianyan/core/utils/safe_json.dart'; + class UserModel { const UserModel({ required this.id, @@ -133,30 +135,61 @@ class UserModel { /// 从JSON解析sec_question,兼容顶层和extra嵌套两种路径 /// 服务端UserCenter接口返回: extra.sec_question.question_id /// 服务端changeSecQuestion接口返回: sec_question (顶层int) + /// + /// 安全类型转换:PHP/JSON 返回的整数常为 num 类型(如 1.0), + /// 不能用 `is int` 严格判断,必须用 `(x as num?)?.toInt()`。 static int _parseSecQuestion(Map json) { + // 顶层路径:sec_question 可能是 int / num / String final topLevel = json['sec_question']; - if (topLevel is int && topLevel > 0) return topLevel; + final topInt = _toSafeInt(topLevel); + if (topInt > 0) return topInt; + + // 嵌套路径:extra.sec_question final extra = json['extra']; if (extra is Map) { final nested = extra['sec_question']; - if (nested is int && nested > 0) return nested; + // 嵌套可能是 int / num / String / Map + final nestedInt = _toSafeInt(nested); + if (nestedInt > 0) return nestedInt; if (nested is Map) { - return nested['question_id'] as int? ?? 0; + final qid = _toSafeInt(nested['question_id']); + if (qid > 0) return qid; } } return 0; } + /// 安全将动态值转为 int + /// 支持 int / double / num / String / 含数字的字符串 + static int _toSafeInt(dynamic value) { + if (value == null) return 0; + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) { + return int.tryParse(value.trim()) ?? 0; + } + return 0; + } + /// 从JSON解析sec_question_text,兼容顶层和extra嵌套两种路径 static String _parseSecQuestionText(Map json) { final topLevel = json['sec_question_text']; if (topLevel is String && topLevel.isNotEmpty) return topLevel; + // 兼容顶层 sec_question_text 为非 String 类型(如 num)的情况 + if (topLevel != null && topLevel.toString().isNotEmpty) { + final str = topLevel.toString(); + if (str.isNotEmpty && str != 'null') return str; + } final extra = json['extra']; if (extra is Map) { final nested = extra['sec_question']; if (nested is Map) { final text = nested['question_text']; if (text is String && text.isNotEmpty) return text; + if (text != null && text.toString().isNotEmpty) { + final str = text.toString(); + if (str != 'null') return str; + } } } return ''; @@ -192,7 +225,7 @@ class UserModel { json['verification'] as Map, ) : null, - isOnline: json['is_online'] as int? ?? 0, + isOnline: SafeJson.parseInt(json['is_online']), vip: json['vip'] != null ? UserVip.fromJson(json['vip'] as Map) : null, @@ -335,7 +368,7 @@ class UserTitle { factory UserTitle.fromJson(Map json) { return UserTitle( - id: json['id'] as int? ?? 1, + id: SafeJson.parseInt(json['id'], 1), name: json['name'] as String? ?? '新手', icon: json['icon'] as String? ?? '', color: json['color'] as String? ?? '#999999', @@ -392,8 +425,8 @@ class UserVip { factory UserVip.fromJson(Map json) { return UserVip( isVip: json['is_vip'] as bool? ?? false, - startTime: json['start_time'] as int? ?? 0, - endTime: json['end_time'] as int? ?? 0, + startTime: SafeJson.parseInt(json['start_time']), + endTime: SafeJson.parseInt(json['end_time']), startDate: json['start_date'] as String? ?? '', endDate: json['end_date'] as String? ?? '', ); diff --git a/lib/core/network/cache_config.dart b/lib/core/network/cache_config.dart index 0e75e85a..fa125ebd 100644 --- a/lib/core/network/cache_config.dart +++ b/lib/core/network/cache_config.dart @@ -1,18 +1,19 @@ /// ============================================================ /// 闲言APP — Dio HTTP 缓存配置 /// 创建时间: 2026-05-27 -/// 更新时间: 2026-06-15 +/// 更新时间: 2026-06-19 /// 作用: 配置 dio_cache_interceptor 缓存策略 /// GET 请求默认缓存5分钟,特定接口可自定义 /// 排除需要实时数据的接口(登录、签到等) /// 双层缓存: L1内存(快速) + L2 Hive持久化(重启不丢失) -/// 上次更新: 升级dio_cache_interceptor 4.x,hitCacheOnErrorExcept→hitCacheOnNetworkFailure,Nullable→Duration?,CacheResponse添加statusCode +/// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0 /// ============================================================ import 'dart:async'; import 'package:dio_cache_interceptor/dio_cache_interceptor.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../utils/logger.dart'; import '../storage/hive_safe_access.dart'; @@ -89,9 +90,9 @@ class HiveCacheStore extends CacheStore { final map = Map.from(raw); return CacheResponse( cacheControl: CacheControl( - maxAge: map['cc_maxAge'] as int? ?? -1, - maxStale: map['cc_maxStale'] as int? ?? -1, - minFresh: map['cc_minFresh'] as int? ?? -1, + maxAge: SafeJson.parseInt(map['cc_maxAge'], -1), + maxStale: SafeJson.parseInt(map['cc_maxStale'], -1), + minFresh: SafeJson.parseInt(map['cc_minFresh'], -1), mustRevalidate: map['cc_mustRevalidate'] as bool? ?? false, privacy: map['cc_privacy'] as String?, noCache: map['cc_noCache'] as bool? ?? false, @@ -118,11 +119,11 @@ class HiveCacheStore extends CacheStore { maxStale: map['maxStale'] != null ? DateTime.parse(map['maxStale'] as String) : null, - priority: CachePriority.values[map['priority'] as int? ?? 1], + priority: CachePriority.values[SafeJson.parseInt(map['priority'], 1)], requestDate: DateTime.parse(map['requestDate'] as String), responseDate: DateTime.parse(map['responseDate'] as String), url: map['url'] as String, - statusCode: map['statusCode'] as int? ?? 200, + statusCode: SafeJson.parseInt(map['statusCode'], 200), ); } diff --git a/lib/core/providers/split_view_provider.dart b/lib/core/providers/split_view_provider.dart index 3207bd4a..79345d52 100644 --- a/lib/core/providers/split_view_provider.dart +++ b/lib/core/providers/split_view_provider.dart @@ -1,11 +1,13 @@ /// ============================================================ /// 闲言APP — 宽屏分屏状态管理 /// 创建时间: 2026-05-29 -/// 更新时间: 2026-05-31 -/// 作用: 管理分屏比例、右侧面板内容、导航栏位置、分屏开关 -/// 上次更新: @Default 和 build fallback 统一引用 DefaultSettings +/// 更新时间: 2026-06-18 +/// 作用: 管理分屏比例、右侧面板内容、导航栏位置、分屏开关、工作台模式 +/// 上次更新: 新增工作台交互增强字段(专注阅读/右栏分屏/拖拽出窗/右栏标签页/中栏拖拽排序/毛玻璃/空状态动画) /// ============================================================ +import 'dart:convert'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:xianyan/core/storage/kv_storage.dart'; @@ -63,9 +65,21 @@ class SplitRatioOption { const String _kSplitRatio = 'split_view_ratio'; const String _kNavBarPosition = 'nav_bar_position'; const String _kSplitViewEnabled = 'split_view_enabled'; +const String _kWorkbenchEnabled = 'workbench_enabled'; +const String _kFocusReadingMode = 'workbench_focus_reading'; +const String _kRightPanelSplit = 'workbench_right_panel_split'; +const String _kPopOutWindow = 'workbench_pop_out_window'; +const String _kRightPanelTabs = 'workbench_right_panel_tabs'; +const String _kMiddlePanelDragSort = 'workbench_middle_drag_sort'; +const String _kWorkbenchBlurBackground = 'workbench_blur_bg'; +const String _kEmptyStateAnimation = 'workbench_empty_anim'; +const String _kNavBarCollapsed = 'nav_bar_collapsed'; +const String _kNavBarWidth = 'workbench_nav_bar_width'; +const String _kTabSplitRatios = 'tab_split_ratios'; -/// 三栏布局断点:宽度 >= 1400px 进入三栏模式 -const double kTripleColumnBreakpoint = 1400.0; +/// @deprecated 旧三栏断点,保留向后兼容,实际指向 kMediumBreakpoint (1024.0) +/// 新断点常量定义在 adaptive_split_view.dart +const double kTripleColumnBreakpoint = 1024.0; /// 分屏视图状态(freezed不可变数据类) @freezed @@ -77,12 +91,39 @@ sealed class SplitViewState with _$SplitViewState { Map? rightPanelArgs, @Default(NavBarPosition.left) NavBarPosition navBarPosition, @Default(false) bool splitViewEnabled, + /// 工作台模式开关(宽屏自动开启,用户可手动关闭) + @Default(true) bool workbenchEnabled, String? homeRightPanel, String? discoverRightPanel, String? profileRightPanel, @Default(0) int currentTab, String? thirdPanelContent, Map? thirdPanelArgs, + + // === 工作台交互增强(P0-8 新增)=== + /// 专注阅读模式:隐藏中栏+导航栏,右栏全宽沉浸 + @Default(false) bool focusReadingMode, + /// 右栏分屏:超宽屏(≥1920px)时右栏再分屏 + @Default(false) bool rightPanelSplit, + /// 拖拽出窗(占位,需多窗口支持) + @Default(false) bool popOutWindow, + /// 右栏标签页(占位,复用 RightPanelStackNotifier 扩展) + @Default(false) bool rightPanelTabs, + /// 中栏拖拽排序 + @Default(false) bool middlePanelDragSort, + /// 工作台毛玻璃背景(仅桌面端) + @Default(true) bool workbenchBlurBackground, + /// 空状态动画 + @Default(true) bool emptyStateAnimation, + + // === 侧边栏折叠 + 分屏记忆(2026-06-18 新增)=== + /// 导航栏折叠状态(折叠时仅显示图标 48px) + @Default(false) bool navBarCollapsed, + /// 导航栏宽度(展开态可拖拽调节,默认 72.0) + @Default(72.0) double navBarWidth, + /// 各 Tab 独立分屏比例(key=tabIndex, value=ratio) + /// 切换 Tab 时恢复该 Tab 上次的分屏比例 + @Default({}) Map tabSplitRatios, }) = _SplitViewState; String? get activeRightPanel => switch (currentTab) { @@ -99,6 +140,21 @@ class SplitViewNotifier extends Notifier { SplitViewState build() { final savedIndex = KvStorage.getInt(_kNavBarPosition) ?? 0; const positions = NavBarPosition.values; + + // 读取分屏记忆(JSON 序列化的 Map) + final tabRatiosJson = KvStorage.getString(_kTabSplitRatios); + Map tabSplitRatios = {}; + if (tabRatiosJson != null && tabRatiosJson.isNotEmpty) { + try { + final decoded = jsonDecode(tabRatiosJson) as Map; + tabSplitRatios = decoded.map( + (k, v) => MapEntry(int.parse(k), (v as num).toDouble()), + ); + } catch (_) { + // 解析失败使用空 Map + } + } + return SplitViewState( splitRatio: KvStorage.getDouble(_kSplitRatio) ?? 0.4, navBarPosition: (savedIndex >= 0 && savedIndex < positions.length) @@ -107,6 +163,18 @@ class SplitViewNotifier extends Notifier { splitViewEnabled: KvStorage.getBool(_kSplitViewEnabled) ?? DefaultSettings.splitViewEnabled, + workbenchEnabled: + KvStorage.getBool(_kWorkbenchEnabled) ?? true, + focusReadingMode: KvStorage.getBool(_kFocusReadingMode) ?? false, + rightPanelSplit: KvStorage.getBool(_kRightPanelSplit) ?? false, + popOutWindow: KvStorage.getBool(_kPopOutWindow) ?? false, + rightPanelTabs: KvStorage.getBool(_kRightPanelTabs) ?? false, + middlePanelDragSort: KvStorage.getBool(_kMiddlePanelDragSort) ?? false, + workbenchBlurBackground: KvStorage.getBool(_kWorkbenchBlurBackground) ?? true, + emptyStateAnimation: KvStorage.getBool(_kEmptyStateAnimation) ?? true, + navBarCollapsed: KvStorage.getBool(_kNavBarCollapsed) ?? false, + navBarWidth: KvStorage.getDouble(_kNavBarWidth) ?? 72.0, + tabSplitRatios: tabSplitRatios, ); } @@ -129,6 +197,113 @@ class SplitViewNotifier extends Notifier { state = state.copyWith(splitViewEnabled: enabled); } + /// 设置工作台模式开关(宽屏自动开启,用户可手动关闭) + void setWorkbenchEnabled(bool enabled) { + KvStorage.setBool(_kWorkbenchEnabled, enabled); + state = state.copyWith(workbenchEnabled: enabled); + } + + // === 工作台交互增强 setter(P0-8 新增)=== + + /// 设置专注阅读模式 + void setFocusReadingMode(bool enabled) { + KvStorage.setBool(_kFocusReadingMode, enabled); + state = state.copyWith(focusReadingMode: enabled); + } + + /// 设置右栏分屏 + void setRightPanelSplit(bool enabled) { + KvStorage.setBool(_kRightPanelSplit, enabled); + state = state.copyWith(rightPanelSplit: enabled); + } + + /// 设置拖拽出窗(占位) + void setPopOutWindow(bool enabled) { + KvStorage.setBool(_kPopOutWindow, enabled); + state = state.copyWith(popOutWindow: enabled); + } + + /// 设置右栏标签页(占位) + void setRightPanelTabs(bool enabled) { + KvStorage.setBool(_kRightPanelTabs, enabled); + state = state.copyWith(rightPanelTabs: enabled); + } + + /// 设置中栏拖拽排序 + void setMiddlePanelDragSort(bool enabled) { + KvStorage.setBool(_kMiddlePanelDragSort, enabled); + state = state.copyWith(middlePanelDragSort: enabled); + } + + /// 设置工作台毛玻璃背景 + void setWorkbenchBlurBackground(bool enabled) { + KvStorage.setBool(_kWorkbenchBlurBackground, enabled); + state = state.copyWith(workbenchBlurBackground: enabled); + } + + /// 设置空状态动画 + void setEmptyStateAnimation(bool enabled) { + KvStorage.setBool(_kEmptyStateAnimation, enabled); + state = state.copyWith(emptyStateAnimation: enabled); + } + + // === 侧边栏折叠 + 分屏记忆 setter(2026-06-18 新增)=== + + /// 设置导航栏折叠状态 + /// 折叠时仅显示图标(48px),展开时恢复原宽度 + void setNavBarCollapsed(bool collapsed) { + KvStorage.setBool(_kNavBarCollapsed, collapsed); + state = state.copyWith(navBarCollapsed: collapsed); + } + + /// 切换导航栏折叠状态 + void toggleNavBarCollapsed() { + setNavBarCollapsed(!state.navBarCollapsed); + } + + /// 设置导航栏宽度(展开态可拖拽调节) + /// 范围限制:48.0(最小折叠态)~ 240.0(最大展开态) + void setNavBarWidth(double width) { + final clamped = width.clamp(48.0, 240.0); + KvStorage.setDouble(_kNavBarWidth, clamped); + state = state.copyWith(navBarWidth: clamped); + } + + /// 保存当前 Tab 的分屏比例到记忆 + void saveCurrentTabSplitRatio() { + final tab = state.currentTab; + final ratio = state.splitRatio; + final newRatios = Map.from(state.tabSplitRatios); + newRatios[tab] = ratio; + // JSON 序列化(key 必须是 String) + final jsonMap = newRatios.map((k, v) => MapEntry(k.toString(), v)); + KvStorage.setString(_kTabSplitRatios, jsonEncode(jsonMap)); + state = state.copyWith(tabSplitRatios: newRatios); + } + + /// 切换 Tab 并恢复该 Tab 上次的分屏比例 + void setCurrentTabWithMemory(int index) { + // 先保存当前 Tab 的比例 + final currentTab = state.currentTab; + final currentRatio = state.splitRatio; + final newRatios = Map.from(state.tabSplitRatios); + newRatios[currentTab] = currentRatio; + + // 恢复目标 Tab 的比例(如果有记忆) + final restoredRatio = newRatios[index] ?? 0.4; + + // 持久化 + final jsonMap = newRatios.map((k, v) => MapEntry(k.toString(), v)); + KvStorage.setString(_kTabSplitRatios, jsonEncode(jsonMap)); + KvStorage.setDouble(_kSplitRatio, restoredRatio); + + state = state.copyWith( + currentTab: index, + splitRatio: restoredRatio, + tabSplitRatios: newRatios, + ); + } + void setHomeRightPanel(String? panelId, {Map? args}) { state = state.copyWith(homeRightPanel: panelId, rightPanelArgs: args); } diff --git a/lib/core/providers/split_view_provider.freezed.dart b/lib/core/providers/split_view_provider.freezed.dart index 46978070..e6aeeb46 100644 --- a/lib/core/providers/split_view_provider.freezed.dart +++ b/lib/core/providers/split_view_provider.freezed.dart @@ -25,12 +25,23 @@ mixin _$SplitViewState { Map? get rightPanelArgs; NavBarPosition get navBarPosition; bool get splitViewEnabled; + bool get workbenchEnabled; String? get homeRightPanel; String? get discoverRightPanel; String? get profileRightPanel; int get currentTab; String? get thirdPanelContent; Map? get thirdPanelArgs; + bool get focusReadingMode; + bool get rightPanelSplit; + bool get popOutWindow; + bool get rightPanelTabs; + bool get middlePanelDragSort; + bool get workbenchBlurBackground; + bool get emptyStateAnimation; + bool get navBarCollapsed; + double get navBarWidth; + Map get tabSplitRatios; @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') @@ -53,6 +64,8 @@ mixin _$SplitViewState { other.navBarPosition == navBarPosition) && (identical(other.splitViewEnabled, splitViewEnabled) || other.splitViewEnabled == splitViewEnabled) && + (identical(other.workbenchEnabled, workbenchEnabled) || + other.workbenchEnabled == workbenchEnabled) && (identical(other.homeRightPanel, homeRightPanel) || other.homeRightPanel == homeRightPanel) && (identical(other.discoverRightPanel, discoverRightPanel) || @@ -64,27 +77,59 @@ mixin _$SplitViewState { (identical(other.thirdPanelContent, thirdPanelContent) || other.thirdPanelContent == thirdPanelContent) && const DeepCollectionEquality() - .equals(other.thirdPanelArgs, thirdPanelArgs)); + .equals(other.thirdPanelArgs, thirdPanelArgs) && + (identical(other.focusReadingMode, focusReadingMode) || + other.focusReadingMode == focusReadingMode) && + (identical(other.rightPanelSplit, rightPanelSplit) || + other.rightPanelSplit == rightPanelSplit) && + (identical(other.popOutWindow, popOutWindow) || + other.popOutWindow == popOutWindow) && + (identical(other.rightPanelTabs, rightPanelTabs) || + other.rightPanelTabs == rightPanelTabs) && + (identical(other.middlePanelDragSort, middlePanelDragSort) || + other.middlePanelDragSort == middlePanelDragSort) && + (identical(other.workbenchBlurBackground, workbenchBlurBackground) || + other.workbenchBlurBackground == workbenchBlurBackground) && + (identical(other.emptyStateAnimation, emptyStateAnimation) || + other.emptyStateAnimation == emptyStateAnimation) && + (identical(other.navBarCollapsed, navBarCollapsed) || + other.navBarCollapsed == navBarCollapsed) && + (identical(other.navBarWidth, navBarWidth) || + other.navBarWidth == navBarWidth) && + const DeepCollectionEquality() + .equals(other.tabSplitRatios, tabSplitRatios)); } @override - int get hashCode => Object.hash( + int get hashCode => Object.hashAll([ runtimeType, splitRatio, rightPanelContent, const DeepCollectionEquality().hash(rightPanelArgs), navBarPosition, splitViewEnabled, + workbenchEnabled, homeRightPanel, discoverRightPanel, profileRightPanel, currentTab, thirdPanelContent, - const DeepCollectionEquality().hash(thirdPanelArgs)); + const DeepCollectionEquality().hash(thirdPanelArgs), + focusReadingMode, + rightPanelSplit, + popOutWindow, + rightPanelTabs, + middlePanelDragSort, + workbenchBlurBackground, + emptyStateAnimation, + navBarCollapsed, + navBarWidth, + const DeepCollectionEquality().hash(tabSplitRatios), + ]); @override String toString() { - return 'SplitViewState(splitRatio: $splitRatio, rightPanelContent: $rightPanelContent, rightPanelArgs: $rightPanelArgs, navBarPosition: $navBarPosition, splitViewEnabled: $splitViewEnabled, homeRightPanel: $homeRightPanel, discoverRightPanel: $discoverRightPanel, profileRightPanel: $profileRightPanel, currentTab: $currentTab, thirdPanelContent: $thirdPanelContent, thirdPanelArgs: $thirdPanelArgs)'; + return 'SplitViewState(splitRatio: $splitRatio, rightPanelContent: $rightPanelContent, rightPanelArgs: $rightPanelArgs, navBarPosition: $navBarPosition, splitViewEnabled: $splitViewEnabled, workbenchEnabled: $workbenchEnabled, homeRightPanel: $homeRightPanel, discoverRightPanel: $discoverRightPanel, profileRightPanel: $profileRightPanel, currentTab: $currentTab, thirdPanelContent: $thirdPanelContent, thirdPanelArgs: $thirdPanelArgs, focusReadingMode: $focusReadingMode, rightPanelSplit: $rightPanelSplit, popOutWindow: $popOutWindow, rightPanelTabs: $rightPanelTabs, middlePanelDragSort: $middlePanelDragSort, workbenchBlurBackground: $workbenchBlurBackground, emptyStateAnimation: $emptyStateAnimation, navBarCollapsed: $navBarCollapsed, navBarWidth: $navBarWidth, tabSplitRatios: $tabSplitRatios)'; } } @@ -100,12 +145,23 @@ abstract mixin class $SplitViewStateCopyWith<$Res> { Map? rightPanelArgs, NavBarPosition navBarPosition, bool splitViewEnabled, + bool workbenchEnabled, String? homeRightPanel, String? discoverRightPanel, String? profileRightPanel, int currentTab, String? thirdPanelContent, - Map? thirdPanelArgs}); + Map? thirdPanelArgs, + bool focusReadingMode, + bool rightPanelSplit, + bool popOutWindow, + bool rightPanelTabs, + bool middlePanelDragSort, + bool workbenchBlurBackground, + bool emptyStateAnimation, + bool navBarCollapsed, + double navBarWidth, + Map tabSplitRatios}); } /// @nodoc @@ -124,12 +180,23 @@ class _$SplitViewStateCopyWithImpl<$Res> Object? rightPanelArgs = _undefined, Object? navBarPosition = _undefined, Object? splitViewEnabled = _undefined, + Object? workbenchEnabled = _undefined, Object? homeRightPanel = _undefined, Object? discoverRightPanel = _undefined, Object? profileRightPanel = _undefined, Object? currentTab = _undefined, Object? thirdPanelContent = _undefined, Object? thirdPanelArgs = _undefined, + Object? focusReadingMode = _undefined, + Object? rightPanelSplit = _undefined, + Object? popOutWindow = _undefined, + Object? rightPanelTabs = _undefined, + Object? middlePanelDragSort = _undefined, + Object? workbenchBlurBackground = _undefined, + Object? emptyStateAnimation = _undefined, + Object? navBarCollapsed = _undefined, + Object? navBarWidth = _undefined, + Object? tabSplitRatios = _undefined, }) { return _then(_SplitViewState( splitRatio: identical(splitRatio, _undefined) @@ -147,6 +214,9 @@ class _$SplitViewStateCopyWithImpl<$Res> splitViewEnabled: identical(splitViewEnabled, _undefined) ? _self.splitViewEnabled : splitViewEnabled as bool, + workbenchEnabled: identical(workbenchEnabled, _undefined) + ? _self.workbenchEnabled + : workbenchEnabled as bool, homeRightPanel: identical(homeRightPanel, _undefined) ? _self.homeRightPanel : homeRightPanel as String?, @@ -165,6 +235,36 @@ class _$SplitViewStateCopyWithImpl<$Res> thirdPanelArgs: identical(thirdPanelArgs, _undefined) ? _self.thirdPanelArgs : thirdPanelArgs as Map?, + focusReadingMode: identical(focusReadingMode, _undefined) + ? _self.focusReadingMode + : focusReadingMode as bool, + rightPanelSplit: identical(rightPanelSplit, _undefined) + ? _self.rightPanelSplit + : rightPanelSplit as bool, + popOutWindow: identical(popOutWindow, _undefined) + ? _self.popOutWindow + : popOutWindow as bool, + rightPanelTabs: identical(rightPanelTabs, _undefined) + ? _self.rightPanelTabs + : rightPanelTabs as bool, + middlePanelDragSort: identical(middlePanelDragSort, _undefined) + ? _self.middlePanelDragSort + : middlePanelDragSort as bool, + workbenchBlurBackground: identical(workbenchBlurBackground, _undefined) + ? _self.workbenchBlurBackground + : workbenchBlurBackground as bool, + emptyStateAnimation: identical(emptyStateAnimation, _undefined) + ? _self.emptyStateAnimation + : emptyStateAnimation as bool, + navBarCollapsed: identical(navBarCollapsed, _undefined) + ? _self.navBarCollapsed + : navBarCollapsed as bool, + navBarWidth: identical(navBarWidth, _undefined) + ? _self.navBarWidth + : navBarWidth as double, + tabSplitRatios: identical(tabSplitRatios, _undefined) + ? _self.tabSplitRatios + : tabSplitRatios as Map, ) as SplitViewState); } } @@ -177,12 +277,23 @@ class _SplitViewState extends SplitViewState { this.rightPanelArgs, this.navBarPosition = NavBarPosition.left, this.splitViewEnabled = false, + this.workbenchEnabled = true, this.homeRightPanel, this.discoverRightPanel, this.profileRightPanel, this.currentTab = 0, this.thirdPanelContent, - this.thirdPanelArgs}) + this.thirdPanelArgs, + this.focusReadingMode = false, + this.rightPanelSplit = false, + this.popOutWindow = false, + this.rightPanelTabs = false, + this.middlePanelDragSort = false, + this.workbenchBlurBackground = true, + this.emptyStateAnimation = true, + this.navBarCollapsed = false, + this.navBarWidth = 72.0, + this.tabSplitRatios = const {}}) : super._(); @override @@ -196,6 +307,8 @@ class _SplitViewState extends SplitViewState { @override final bool splitViewEnabled; @override + final bool workbenchEnabled; + @override final String? homeRightPanel; @override final String? discoverRightPanel; @@ -207,6 +320,26 @@ class _SplitViewState extends SplitViewState { final String? thirdPanelContent; @override final Map? thirdPanelArgs; + @override + final bool focusReadingMode; + @override + final bool rightPanelSplit; + @override + final bool popOutWindow; + @override + final bool rightPanelTabs; + @override + final bool middlePanelDragSort; + @override + final bool workbenchBlurBackground; + @override + final bool emptyStateAnimation; + @override + final bool navBarCollapsed; + @override + final double navBarWidth; + @override + final Map tabSplitRatios; @override @JsonKey(includeFromJson: false, includeToJson: false) diff --git a/lib/core/router/app_nav_extension.dart b/lib/core/router/app_nav_extension.dart index e97e9f11..8cbdfa1a 100644 --- a/lib/core/router/app_nav_extension.dart +++ b/lib/core/router/app_nav_extension.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 跨平台导航扩展 // 创建时间: 2026-05-18 -// 更新时间: 2026-06-01 +// 更新时间: 2026-06-19 // 作用: 统一导航API,鸿蒙端使用OhosNavBridge,其他端使用GoRouter;自动记录最近路由 -// 上次更新: appGo()也记录最近路由,确保所有导航方式都留下记录 +// 上次更新: 新增 appPop/appCanPop 工作台感知方法,修复右栏页面 Navigator.pop 导致根栈被弹空的白屏问题 // ============================================================ import 'package:flutter/widgets.dart'; @@ -12,12 +12,56 @@ import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu; import 'package:xianyan/core/router/ohos_nav_bridge.dart'; import 'package:xianyan/features/settings/providers/general_settings_provider.dart'; import 'package:xianyan/core/services/recent_route_service.dart'; +import 'package:xianyan/core/router/route_registry.dart'; +import 'package:xianyan/core/layout/workbench/right_panel_navigator.dart'; +import 'package:xianyan/core/layout/adaptive_split_view.dart' show kCompactBreakpoint; +import 'package:xianyan/core/providers/split_view_provider.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 工作台模式导航辅助 +/// +/// 在宽屏工作台模式下,将非全屏路由重定向到右栏嵌套 Navigator +class WorkbenchNavHelper { + /// 判断当前是否处于工作台模式 + /// + /// 所有平台(桌面/移动/Web)在宽度 >= 768px 且工作台开关开启时自动启用 + /// 仅鸿蒙端因独立布局不走工作台模式 + static bool isWorkbenchMode(BuildContext context, WidgetRef ref) { + // 鸿蒙端不走工作台模式(独立布局) + if (pu.isOhos) return false; + final screenWidth = MediaQuery.sizeOf(context).width; + final splitState = ref.read(splitViewProvider); + return screenWidth >= kCompactBreakpoint && splitState.workbenchEnabled; + } + + /// 判断路由是否应该进入右栏(非全屏路由) + static bool shouldOpenInRightPanel(String route) { + return !isFullScreenRoute(route); + } + + /// 在右栏 push 一个页面 + static void pushToRightPanel( + WidgetRef ref, + int currentTab, + String route, { + Object? extra, + String? title, + }) { + final entry = RightPanelEntry( + route: route, + title: title ?? inferRouteTitle(route), + extra: extra, + ); + ref.read(rightPanelStackProvider.notifier).push(currentTab, entry); + } +} extension AppNavExtension on BuildContext { Future appPush( String route, { Object? extra, PageTransitionMode? transitionMode, + String? title, }) { RecentRouteService.addRecentRoute(route); if (pu.isOhos) { @@ -28,6 +72,29 @@ extension AppNavExtension on BuildContext { transitionMode: transitionMode, ); } + + // 工作台模式判断:宽屏 + 非全屏路由 → push 到右栏嵌套 Navigator + if (!pu.isOhos) { + final screenWidth = MediaQuery.sizeOf(this).width; + if (screenWidth >= kCompactBreakpoint) { + // 通过 ProviderScope 获取 ProviderContainer + final container = ProviderScope.containerOf(this); + final splitState = container.read(splitViewProvider); + if (splitState.workbenchEnabled && !isFullScreenRoute(route)) { + // 工作台模式:push 到右栏嵌套 Navigator + final entry = RightPanelEntry( + route: route, + title: title ?? inferRouteTitle(route), + extra: extra, + ); + container + .read(rightPanelStackProvider.notifier) + .push(splitState.currentTab, entry); + return Future.value(); + } + } + } + return push(route, extra: extra); } @@ -48,4 +115,157 @@ extension AppNavExtension on BuildContext { } go(route); } + + // ============================================================ + // 工作台模式感知的 pop / canPop + // ============================================================ + + /// 工作台模式感知的 pop + /// + /// - 宽屏工作台模式 → pop 右栏嵌套栈 + /// - 窄屏或非工作台 → 走 Navigator.pop(兼容 go_router) + /// + /// 修复问题:右栏页面直接调用 `Navigator.pop(context)` 会操作根 GoRouter + /// Navigator,导致栈被弹空出现白屏断言错误。 + void appPop([T? result]) { + if (pu.isOhos) { + Navigator.pop(this, result); + return; + } + + // 工作台模式判断:宽屏 + 工作台开关开启 → pop 右栏栈 + final screenWidth = MediaQuery.sizeOf(this).width; + if (screenWidth >= kCompactBreakpoint) { + final container = ProviderScope.containerOf(this); + final splitState = container.read(splitViewProvider); + if (splitState.workbenchEnabled) { + // 工作台模式:pop 右栏栈顶页面 + container + .read(rightPanelStackProvider.notifier) + .pop(splitState.currentTab); + return; + } + } + + // 正常模式:使用 Navigator.pop + Navigator.pop(this, result); + } + + /// 工作台模式感知的 canPop + /// + /// - 宽屏工作台模式 → 检查右栏嵌套栈是否非空 + /// - 窄屏或非工作台 → 走 Navigator.canPop + bool appCanPop() { + if (pu.isOhos) { + return Navigator.of(this).canPop(); + } + + // 工作台模式判断 + final screenWidth = MediaQuery.sizeOf(this).width; + if (screenWidth >= kCompactBreakpoint) { + final container = ProviderScope.containerOf(this); + final splitState = container.read(splitViewProvider); + if (splitState.workbenchEnabled) { + // 工作台模式:检查右栏栈是否有页面 + final stackMap = container.read(rightPanelStackProvider); + final stack = stackMap[splitState.currentTab]; + return stack != null && stack.entries.isNotEmpty; + } + } + + // 正常模式 + return Navigator.of(this).canPop(); + } +} + +// ============================================================ +// ConsumerContext 扩展 — 工作台模式感知的导航 +// ============================================================ + +/// 工作台模式感知的导航扩展 +/// +/// 使用方式: +/// ```dart +/// ref.appPush(context, '/sentence/123', extra: {...}); +/// ``` +extension WorkbenchNavExtension on WidgetRef { + /// 工作台模式感知的 push + /// + /// - 宽屏工作台模式 + 非全屏路由 → push 到右栏嵌套 Navigator + /// - 窄屏 或 全屏路由 → 走 GoRouter rootNavigator push + Future appPush( + BuildContext context, + String route, { + Object? extra, + PageTransitionMode? transitionMode, + String? title, + }) { + RecentRouteService.addRecentRoute(route); + + // 鸿蒙端走 OhosNavBridge + if (pu.isOhos) { + return OhosNavBridge.push( + context, + route, + extra: extra, + transitionMode: transitionMode, + ); + } + + // 工作台模式判断 + final isWorkbench = WorkbenchNavHelper.isWorkbenchMode(context, this); + final shouldRightPanel = WorkbenchNavHelper.shouldOpenInRightPanel(route); + + if (isWorkbench && shouldRightPanel) { + // 宽屏工作台模式:push 到右栏嵌套 Navigator + final currentTab = read(splitViewProvider).currentTab; + WorkbenchNavHelper.pushToRightPanel( + this, + currentTab, + route, + extra: extra, + title: title, + ); + return Future.value(); + } + + // 窄屏或全屏路由:走 GoRouter push + return context.push(route, extra: extra); + } + + /// 工作台模式感知的 pop(WidgetRef 版本) + /// + /// - 宽屏工作台模式 → pop 右栏嵌套栈 + /// - 窄屏或非工作台 → 走 Navigator.pop + void appPop(BuildContext context, [T? result]) { + if (pu.isOhos) { + Navigator.pop(context, result); + return; + } + + final isWorkbench = WorkbenchNavHelper.isWorkbenchMode(context, this); + if (isWorkbench) { + final currentTab = read(splitViewProvider).currentTab; + read(rightPanelStackProvider.notifier).pop(currentTab); + return; + } + + Navigator.pop(context, result); + } + + /// 工作台模式感知的 canPop(WidgetRef 版本) + bool appCanPop(BuildContext context) { + if (pu.isOhos) { + return Navigator.of(context).canPop(); + } + + if (WorkbenchNavHelper.isWorkbenchMode(context, this)) { + final currentTab = read(splitViewProvider).currentTab; + final stackMap = read(rightPanelStackProvider); + final stack = stackMap[currentTab]; + return stack != null && stack.entries.isNotEmpty; + } + + return Navigator.of(context).canPop(); + } } diff --git a/lib/core/router/app_routes.dart b/lib/core/router/app_routes.dart index 9a2122ac..71dce0da 100644 --- a/lib/core/router/app_routes.dart +++ b/lib/core/router/app_routes.dart @@ -23,6 +23,7 @@ class AppRoutes { static const String widgetManagement = '/widget-management'; static const String themeSettings = '/settings/theme'; static const String generalSettings = '/settings/general'; + static const String workbenchSettings = '/settings/workbench'; static const String languageSettings = '/settings/language'; static const String accountSettings = '/settings/account'; static const String passwordSettings = '/settings/password'; diff --git a/lib/core/router/route_def.dart b/lib/core/router/route_def.dart index 6670540a..7e02d63c 100644 --- a/lib/core/router/route_def.dart +++ b/lib/core/router/route_def.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 路由定义核心类型 // 创建时间: 2026-06-18 -// 更新时间: 2026-06-09 +// 更新时间: 2026-06-18 // 作用: RouteDef/RouteContext/RouteModule/RouteTransition 统一路由定义类型 -// 上次更新: RouteDef 新增 deepLinkAliases 字段,支持配置驱动深度链接映射 +// 上次更新: RouteDef 新增 fullScreen 字段,用于工作台模式判断是否全屏覆盖 // ============================================================ import 'package:flutter/widgets.dart'; @@ -59,6 +59,10 @@ class RouteDef { this.redirectTo, this.children = const [], this.deepLinkAliases = const [], + /// 是否全屏覆盖(编辑器/图片预览等需要大空间的页面) + /// 工作台模式下,fullScreen=true 的路由仍走 rootNavigator 全屏 push + /// fullScreen=false 的路由在宽屏下进入右栏嵌套 Navigator + this.fullScreen = false, }); final String path; @@ -78,6 +82,9 @@ class RouteDef { /// 解析器会自动从这些别名构建映射表 final List deepLinkAliases; + /// 是否全屏覆盖路由(工作台模式下仍走 rootNavigator) + final bool fullScreen; + bool get isSimple => page != null && builder == null; bool get isRedirect => redirectTo != null; bool get hasChildren => children.isNotEmpty; diff --git a/lib/core/router/route_registry.dart b/lib/core/router/route_registry.dart index ba32678d..e8f4b8aa 100644 --- a/lib/core/router/route_registry.dart +++ b/lib/core/router/route_registry.dart @@ -130,6 +130,7 @@ import 'package:xianyan/features/settings/presentation/plugin/plugin_page.dart'; import 'package:xianyan/features/settings/presentation/plugin/translate_plugin_page.dart'; import 'package:xianyan/features/settings/presentation/plugin/tts_plugin_page.dart'; import 'package:xianyan/features/settings/presentation/experimental_features_page.dart'; +import 'package:xianyan/features/settings/presentation/workbench/workbench_settings_page.dart'; import 'package:xianyan/features/weather/presentation/weather_page.dart'; import 'package:xianyan/features/weather/presentation/weather_settings_page.dart'; @@ -232,7 +233,7 @@ final List routeRegistry = [ name: 'read-later', module: RouteModule.user, page: () => const ReadLaterPage(), - deepLinkAliases: ['/tool/readlater'], + deepLinkAliases: ['/tool/readlater', 'xianyan://readlater'], ), RouteDef( path: AppRoutes.readlaterSettings, @@ -1048,6 +1049,13 @@ final List routeRegistry = [ module: RouteModule.settings, page: () => const ExperimentalFeaturesPage(), ), + RouteDef( + path: AppRoutes.workbenchSettings, + name: 'workbench-settings', + module: RouteModule.settings, + page: () => const WorkbenchSettingsPage(), + deepLinkAliases: ['xianyan://settings/workbench'], + ), // ============================================================ // Feature module @@ -1125,6 +1133,7 @@ final List routeRegistry = [ transition: RouteTransition.heroine, builder: (ctx) => EditorPage(initialText: ctx.getQueryParam('text')), deepLinkAliases: ['xianyan://editor', 'https://s2ss.com/editor'], + fullScreen: true, ), RouteDef( path: AppRoutes.editorPreview, @@ -1140,6 +1149,7 @@ final List routeRegistry = [ ); }, ohosBuilder: (_) => const EditorSubPlaceholder(title: '图片预览'), + fullScreen: true, ), RouteDef( path: AppRoutes.editorCrop, @@ -1150,6 +1160,7 @@ final List routeRegistry = [ return ImageCropPage(imageBytes: extra?.bytes ?? Uint8List(0)); }, ohosBuilder: (_) => const EditorSubPlaceholder(title: '图片裁剪'), + fullScreen: true, ), RouteDef( path: AppRoutes.editorDrafts, @@ -1169,6 +1180,7 @@ final List routeRegistry = [ ); }, ohosBuilder: (_) => const EditorSubPlaceholder(title: '图片画廊'), + fullScreen: true, ), RouteDef( path: AppRoutes.editor3dPreview, @@ -1180,6 +1192,7 @@ final List routeRegistry = [ return Model3DPreviewPage(model: model); }, ohosBuilder: (_) => const EditorSubPlaceholder(title: '3D模型预览'), + fullScreen: true, ), // ============================================================ @@ -1200,3 +1213,75 @@ final List routeRegistry = [ builder: (_) => const AnonymousSubmitPage(), ), ]; + +// ============================================================ +// 路由查询辅助方法 — 供工作台模式判断全屏路由 +// ============================================================ + +/// 根据路径查找 RouteDef +/// +/// 支持带查询参数的路径(自动剥离 ?xxx=yyy 后再匹配), +/// 支持带路径参数的路径(如 /chat_flow/:id 形式的路由按前缀匹配)。 +RouteDef? findRouteDef(String path) { + // 剥离查询参数 + final queryIndex = path.indexOf('?'); + final cleanPath = queryIndex >= 0 ? path.substring(0, queryIndex) : path; + + // 精确匹配 + for (final def in routeRegistry) { + if (def.path == cleanPath) return def; + // 递归查找子路由 + final child = _findInChildren(def.children, cleanPath); + if (child != null) return child; + } + + // 带路径参数的路由按前缀匹配(如 /chat_flow/:id → /chat_flow/xxx) + for (final def in routeRegistry) { + if (_matchParamRoute(def.path, cleanPath)) return def; + final child = _findInChildrenParam(def.children, cleanPath); + if (child != null) return child; + } + + return null; +} + +RouteDef? _findInChildren(List children, String path) { + for (final def in children) { + if (def.path == path) return def; + final child = _findInChildren(def.children, path); + if (child != null) return child; + } + return null; +} + +RouteDef? _findInChildrenParam(List children, String path) { + for (final def in children) { + if (_matchParamRoute(def.path, path)) return def; + final child = _findInChildrenParam(def.children, path); + if (child != null) return child; + } + return null; +} + +/// 判断注册路由是否为带路径参数的路由,且 path 匹配该模式 +/// +/// 示例:pattern='/chat_flow/:id', path='/chat_flow/abc' → true +bool _matchParamRoute(String pattern, String path) { + if (!pattern.contains(':')) return false; + final patternSegs = pattern.split('/'); + final pathSegs = path.split('/'); + if (patternSegs.length != pathSegs.length) return false; + for (var i = 0; i < patternSegs.length; i++) { + final p = patternSegs[i]; + final s = pathSegs[i]; + if (p.startsWith(':')) continue; // 路径参数段,匹配任意 + if (p != s) return false; + } + return true; +} + +/// 判断路由是否为全屏覆盖路由(工作台模式下仍走 rootNavigator) +bool isFullScreenRoute(String path) { + final def = findRouteDef(path); + return def?.fullScreen ?? false; +} diff --git a/lib/core/services/auth/permission_service.dart b/lib/core/services/auth/permission_service.dart index e62a2d2e..5b5dc367 100644 --- a/lib/core/services/auth/permission_service.dart +++ b/lib/core/services/auth/permission_service.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 权限管理服务 /// 创建时间: 2026-04-23 -/// 更新时间: 2026-06-06 +/// 更新时间: 2026-06-19 /// 作用: 统一管理应用权限请求,支持相机/相册/通知/位置/蓝牙/附近设备/麦克风/存储/网络/剪贴板/分享权限 + iOS ATT授权 -/// 上次更新: 鸿蒙端permission_handler不支持时引导用户去系统设置+MissingPluginException捕获 +/// 上次更新: 类型安全修复(int vs num): 权限使用计数使用 SafeJson.parseInt /// ============================================================ import 'dart:async'; @@ -13,6 +13,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:app_tracking_transparency/app_tracking_transparency.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../storage/kv_storage.dart'; @@ -296,7 +297,7 @@ class PermissionService { final key = permission.name; final existing = stats[key]; if (existing != null) { - existing['count'] = (existing['count'] as int) + 1; + existing['count'] = SafeJson.parseInt(existing['count']) + 1; existing['lastUsed'] = DateTime.now().toIso8601String(); } else { stats[key] = { diff --git a/lib/core/services/background/background_task_service.dart b/lib/core/services/background/background_task_service.dart index a95aed9d..842f7369 100644 --- a/lib/core/services/background/background_task_service.dart +++ b/lib/core/services/background/background_task_service.dart @@ -45,6 +45,12 @@ class BackgroundTaskService { return; } + if (pu.isMacOS) { + Log.i('BackgroundTaskService: macOS端 workmanager 不支持,跳过后台任务初始化'); + _initialized = true; + return; + } + try { await Workmanager().initialize( callbackDispatcher, diff --git a/lib/core/services/catcher2_config_service.dart b/lib/core/services/catcher2_config_service.dart index 2a801617..f9dccdc9 100644 --- a/lib/core/services/catcher2_config_service.dart +++ b/lib/core/services/catcher2_config_service.dart @@ -32,7 +32,8 @@ class Catcher2ConfigService { } /// 初始化 Catcher2(使用 rootWidget 而非 runAppFunction,避免 Zone mismatch) - void init({required Widget rootWidget}) { + /// [screenshotsPath] 截图保存路径,非空时启用截图;为空时 Catcher2 会输出 WARNING + void init({required Widget rootWidget, String screenshotsPath = ''}) { final enabled = isEnabled; final debugConfig = enabled @@ -42,12 +43,17 @@ class Catcher2ConfigService { localizationOptions: [ LocalizationOptions.buildDefaultChineseOptions(), ], + screenshotsPath: screenshotsPath, ) - : Catcher2Options(SilentReportMode(), []); + : Catcher2Options(SilentReportMode(), [], screenshotsPath: screenshotsPath); final releaseConfig = enabled - ? Catcher2Options(SilentReportMode(), [_ConsoleLogHandler()]) - : Catcher2Options(SilentReportMode(), []); + ? Catcher2Options( + SilentReportMode(), + [_ConsoleLogHandler()], + screenshotsPath: screenshotsPath, + ) + : Catcher2Options(SilentReportMode(), [], screenshotsPath: screenshotsPath); // 使用 rootWidget 而非 runAppFunction,Catcher2 会在内部调用 runApp // 但不会创建新的 Zone,避免 Zone mismatch 警告 diff --git a/lib/core/services/data/image_cache_metadata_service.dart b/lib/core/services/data/image_cache_metadata_service.dart index b450503b..eb9aa09b 100644 --- a/lib/core/services/data/image_cache_metadata_service.dart +++ b/lib/core/services/data/image_cache_metadata_service.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 图片缓存元数据服务 /// 创建时间: 2026-05-30 -/// 更新时间: 2026-06-05 +/// 更新时间: 2026-06-19 /// 作用: 基于Hive的图片缓存元数据索引,支持按类型/日期分组、过期清理 -/// 上次更新: 修复Web平台兼容性,添加kIsWeb守卫保护文件系统操作 +/// 上次更新: 类型安全修复(int vs num): CacheMetaEntry.fromJson 使用 SafeJson.parseInt /// ============================================================ import 'dart:io'; @@ -11,6 +11,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../storage/kv_storage.dart'; import '../../utils/logger.dart'; @@ -49,7 +50,7 @@ class CacheMetaEntry { factory CacheMetaEntry.fromJson(Map json) { return CacheMetaEntry( path: json['path'] as String, - size: json['size'] as int, + size: SafeJson.parseInt(json['size']), category: json['category'] as String?, sourceUrl: json['sourceUrl'] as String?, createdAt: DateTime.parse(json['createdAt'] as String), diff --git a/lib/core/services/desktop/daily_sentence_viewed_service.dart b/lib/core/services/desktop/daily_sentence_viewed_service.dart new file mode 100644 index 00000000..090d6742 --- /dev/null +++ b/lib/core/services/desktop/daily_sentence_viewed_service.dart @@ -0,0 +1,118 @@ +/// ============================================================ +/// 闲言APP — 每日拾句已查看服务 +/// 创建时间: 2026-06-18 +/// 更新时间: 2026-06-18 +/// 作用: 本地存储已查看的每日拾句 id 列表,用于计算未读数 +/// 上次更新: 初始创建,实现 DailySentenceViewedService +/// ============================================================ + +import '../../storage/kv_storage.dart'; +import '../../utils/logger.dart'; + +/// 每日拾句已查看服务 +/// +/// 使用 KvStorage 持久化已查看的每日拾句 id 列表。 +/// 用于计算每日拾句的未读数(托盘角标)。 +/// +/// 存储格式:JSON 编码的 List,key 为 `viewed_daily_sentence_ids`。 +/// 保留最近 100 条记录,避免无限增长。 +class DailySentenceViewedService { + DailySentenceViewedService._(); + + /// 存储 key + static const String _storageKey = 'viewed_daily_sentence_ids'; + + /// 最大保留记录数 + static const int _maxRecords = 100; + + /// 内存缓存(避免频繁读取 KvStorage) + static Set? _cache; + + /// 获取已查看的每日拾句 id 集合 + static Set getViewedIds() { + if (_cache != null) return _cache!; + try { + final list = KvStorage.getStringList(_storageKey) ?? []; + _cache = list.toSet(); + return _cache!; + } catch (e) { + Log.w('DailySentenceViewedService.getViewedIds 失败: $e'); + _cache = {}; + return _cache!; + } + } + + /// 标记每日拾句为已查看 + /// + /// [id] 每日拾句 id(格式如 `hitokoto_123` 或 `chengyu_456`) + static Future markViewed(String id) async { + if (id.isEmpty) return; + final viewed = getViewedIds(); + if (viewed.contains(id)) return; // 已存在,无需重复写入 + + viewed.add(id); + + // 限制记录数量:保留最新的 _maxRecords 条 + if (viewed.length > _maxRecords) { + final list = viewed.toList()..sort(); + final toRemove = list.take(viewed.length - _maxRecords).toSet(); + viewed.removeAll(toRemove); + } + + _cache = viewed; + try { + await KvStorage.setStringList(_storageKey, viewed.toList()); + } catch (e) { + Log.e('DailySentenceViewedService.markViewed 写入失败: $e'); + } + } + + /// 批量标记已查看 + static Future markViewedBatch(Iterable ids) async { + final viewed = getViewedIds(); + var changed = false; + for (final id in ids) { + if (id.isNotEmpty && !viewed.contains(id)) { + viewed.add(id); + changed = true; + } + } + + if (!changed) return; + + // 限制记录数量 + if (viewed.length > _maxRecords) { + final list = viewed.toList()..sort(); + final toRemove = list.take(viewed.length - _maxRecords).toSet(); + viewed.removeAll(toRemove); + } + + _cache = viewed; + try { + await KvStorage.setStringList(_storageKey, viewed.toList()); + } catch (e) { + Log.e('DailySentenceViewedService.markViewedBatch 写入失败: $e'); + } + } + + /// 检查指定 id 是否已查看 + static bool isViewed(String id) { + if (id.isEmpty) return true; // 空 id 视为已查看 + return getViewedIds().contains(id); + } + + /// 清除所有已查看记录(用于调试/重置) + static Future clear() async { + _cache = {}; + try { + await KvStorage.remove(_storageKey); + } catch (e) { + Log.e('DailySentenceViewedService.clear 失败: $e'); + } + } + + /// 重置内存缓存(用于测试) + static void resetCache() { + _cache = null; + } +} diff --git a/lib/core/services/desktop/desktop_service_registry.dart b/lib/core/services/desktop/desktop_service_registry.dart new file mode 100644 index 00000000..b1411530 --- /dev/null +++ b/lib/core/services/desktop/desktop_service_registry.dart @@ -0,0 +1,63 @@ +/// ============================================================ +/// 闲言APP — 桌面端服务注册表 +/// 创建时间: 2026-06-18 +/// 更新时间: 2026-06-18 +/// 作用: 按平台注入桌面端服务实现(托盘/窗口特效) +/// 上次更新: 初始创建,提供 init() 方法在 main.dart 中调用 +/// ============================================================ + +import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu; +import 'package:xianyan/core/utils/logger.dart'; + +import 'desktop_tray_service.dart'; +import 'desktop_window_effect_service.dart'; +import 'implementations/tray_manager_tray_service.dart'; +import 'implementations/macos_window_effect_service.dart'; +import 'implementations/windows_acrylic_service.dart'; + +/// 桌面端服务注册表 +/// +/// 在 `main.dart` 中调用 `DesktopServiceRegistry.init()` 完成服务注入。 +/// 根据 `pu.isDesktop` / `pu.isMacOS` / `pu.isWindows` 自动选择实现。 +class DesktopServiceRegistry { + DesktopServiceRegistry._(); + + static bool _initialized = false; + + /// 初始化桌面端服务注册表 + /// + /// 在 `main.dart` 的 `main()` 函数中,桌面端窗口初始化之前调用。 + static void init() { + if (_initialized) return; + _initialized = true; + + // 1. 注入托盘服务 + if (pu.isDesktop) { + DesktopTrayService.instance = TrayManagerTrayService(); + Log.i('DesktopServiceRegistry: 注入 TrayManagerTrayService'); + } else { + // 移动端/鸿蒙端使用默认 StubDesktopTrayService + Log.i('DesktopServiceRegistry: 使用 StubDesktopTrayService'); + } + + // 2. 注入窗口特效服务 + if (pu.isMacOS) { + DesktopWindowEffectService.instance = MacosWindowEffectService(); + Log.i('DesktopServiceRegistry: 注入 MacosWindowEffectService'); + } else if (pu.isWindows) { + DesktopWindowEffectService.instance = WindowsAcrylicService(); + Log.i('DesktopServiceRegistry: 注入 WindowsAcrylicService'); + } else { + // Linux/iOS/Android/鸿蒙端使用默认 StubWindowEffectService + Log.i('DesktopServiceRegistry: 使用 StubWindowEffectService'); + } + } + + /// 是否已初始化 + static bool get isInitialized => _initialized; + + /// 重置(用于测试) + static void reset() { + _initialized = false; + } +} diff --git a/lib/core/services/desktop/desktop_tray_menu_builder.dart b/lib/core/services/desktop/desktop_tray_menu_builder.dart new file mode 100644 index 00000000..7a666d5c --- /dev/null +++ b/lib/core/services/desktop/desktop_tray_menu_builder.dart @@ -0,0 +1,284 @@ +/// ============================================================ +/// 闲言APP — 桌面端托盘菜单构建器 +/// 创建时间: 2026-06-18 +/// 更新时间: 2026-06-18 +/// 作用: 构建 4 组分隔线分组的托盘右键菜单(主操作/快速访问/模式切换/系统) +/// 上次更新: 初始创建,实现 TrayMenuCallbacks + TrayMenuLabels + DesktopTrayMenuBuilder +/// ============================================================ + +import 'package:flutter/foundation.dart'; + +import '../../../features/home/presentation/providers/readlater/readlater_entry.dart'; +import '../desktop/desktop_tray_service.dart'; + +/// 托盘菜单项回调集合 +/// +/// 所有回调均为无参无返回值([VoidCallback]), +/// 打开稍后阅读条目除外(接收 entryId)。 +class TrayMenuCallbacks { + /// 新建笔记 + final VoidCallback onNewNote; + + /// 新建灵感 + final VoidCallback onNewInspiration; + + /// 打开稍后阅读页面 + final VoidCallback onOpenReadLater; + + /// 切换工作台模式 + final VoidCallback onToggleWorkbench; + + /// 切换深色/浅色模式 + final VoidCallback onToggleDarkMode; + + /// 显示主窗口(从托盘恢复) + final VoidCallback onShowMainWindow; + + /// 打开偏好设置 + final VoidCallback onOpenSettings; + + /// 退出应用 + final VoidCallback onExit; + + /// 打开指定稍后阅读条目 + final void Function(String entryId) onOpenReadLaterEntry; + + const TrayMenuCallbacks({ + required this.onNewNote, + required this.onNewInspiration, + required this.onOpenReadLater, + required this.onToggleWorkbench, + required this.onToggleDarkMode, + required this.onShowMainWindow, + required this.onOpenSettings, + required this.onExit, + required this.onOpenReadLaterEntry, + }); +} + +/// 托盘菜单标签(支持 i18n) +/// +/// 默认提供中文标签,可通过 [TrayMenuLabels.fromTranslations] 从翻译表生成。 +/// 托盘菜单由原生渲染,无法直接使用 Flutter i18n,需提前获取文本。 +class TrayMenuLabels { + // 第 1 组:主操作 + final String newNote; + final String newInspiration; + final String openReadLater; + + // 第 2 组:快速访问 + final String recentRead; + final String noRecentRead; + + // 第 3 组:模式切换 + final String workbenchMode; + final String darkMode; + + // 第 4 组:系统 + final String showMainWindow; + final String preferences; + final String exitApp; + + // Tooltip + final String tooltip; + final String tooltipWithUnread; + + const TrayMenuLabels({ + required this.newNote, + required this.newInspiration, + required this.openReadLater, + required this.recentRead, + required this.noRecentRead, + required this.workbenchMode, + required this.darkMode, + required this.showMainWindow, + required this.preferences, + required this.exitApp, + required this.tooltip, + required this.tooltipWithUnread, + }); + + /// 默认中文标签 + static const TrayMenuLabels zhCN = TrayMenuLabels( + newNote: '新建笔记', + newInspiration: '新建灵感', + openReadLater: '打开稍后阅读', + recentRead: '最近阅读', + noRecentRead: '暂无最近阅读', + workbenchMode: '工作台模式', + darkMode: '深色模式', + showMainWindow: '显示主窗口', + preferences: '偏好设置', + exitApp: '退出闲言', + tooltip: '闲言', + tooltipWithUnread: '闲言 — {count} 条未读', + ); + + /// 英文标签 + static const TrayMenuLabels enUS = TrayMenuLabels( + newNote: 'New Note', + newInspiration: 'New Inspiration', + openReadLater: 'Open Read Later', + recentRead: 'Recent', + noRecentRead: 'No recent items', + workbenchMode: 'Workbench Mode', + darkMode: 'Dark Mode', + showMainWindow: 'Show Main Window', + preferences: 'Preferences', + exitApp: 'Quit Xianyan', + tooltip: 'Xianyan', + tooltipWithUnread: 'Xianyan — {count} unread', + ); + + /// 根据语言 ID 获取标签 + factory TrayMenuLabels.forLanguage(String languageId) { + switch (languageId) { + case 'en': + return enUS; + case 'zh_CN': + case 'zh_TW': + case 'system': + default: + return zhCN; + } + } + + /// 格式化带未读数的 Tooltip + String formatTooltip(int unreadCount) { + if (unreadCount <= 0) return tooltip; + return tooltipWithUnread.replaceAll('{count}', unreadCount.toString()); + } +} + +/// 桌面端托盘菜单构建器 +/// +/// 构建 4 组分隔线分组的托盘右键菜单: +/// 1. 主操作:新建笔记/灵感、打开稍后阅读 +/// 2. 快速访问:最近阅读子菜单(前 5 条) +/// 3. 模式切换:工作台模式、深色模式 +/// 4. 系统:显示主窗口、偏好设置、退出 +class DesktopTrayMenuBuilder { + DesktopTrayMenuBuilder._(); + + /// 最近阅读子菜单最大条目数 + static const int maxRecentReadItems = 5; + + /// 标题最大长度(超出截断) + static const int maxTitleLength = 24; + + /// 构建托盘菜单 + /// + /// [readLaterEntries] 稍后阅读条目列表(取前 5 条作为最近阅读) + /// [isDark] 当前是否深色模式 + /// [isWorkbenchMode] 是否工作台模式 + /// [labels] 菜单标签 + /// [callbacks] 菜单项回调 + static List build({ + required List readLaterEntries, + required bool isDark, + required bool isWorkbenchMode, + required TrayMenuLabels labels, + required TrayMenuCallbacks callbacks, + }) { + return [ + // ============================================================ + // 第 1 组:主操作 + // ============================================================ + TrayMenuItem( + label: labels.newNote, + shortcut: 'Cmd+N', + onTap: callbacks.onNewNote, + ), + TrayMenuItem( + label: labels.newInspiration, + shortcut: 'Cmd+I', + onTap: callbacks.onNewInspiration, + ), + TrayMenuItem( + label: labels.openReadLater, + shortcut: 'Cmd+R', + onTap: callbacks.onOpenReadLater, + ), + const TrayMenuItem.separator(), + + // ============================================================ + // 第 2 组:快速访问(最近阅读子菜单) + // ============================================================ + TrayMenuItem( + label: labels.recentRead, + submenu: _buildRecentReadSubmenu( + entries: readLaterEntries, + labels: labels, + callbacks: callbacks, + ), + ), + const TrayMenuItem.separator(), + + // ============================================================ + // 第 3 组:模式切换 + // ============================================================ + TrayMenuItem( + label: labels.workbenchMode, + checked: isWorkbenchMode, + onTap: callbacks.onToggleWorkbench, + ), + TrayMenuItem( + label: labels.darkMode, + checked: isDark, + onTap: callbacks.onToggleDarkMode, + ), + const TrayMenuItem.separator(), + + // ============================================================ + // 第 4 组:系统 + // ============================================================ + TrayMenuItem( + label: labels.showMainWindow, + onTap: callbacks.onShowMainWindow, + ), + TrayMenuItem( + label: labels.preferences, + shortcut: 'Cmd+,', + onTap: callbacks.onOpenSettings, + ), + TrayMenuItem( + label: labels.exitApp, + shortcut: 'Cmd+Q', + onTap: callbacks.onExit, + ), + ]; + } + + /// 构建最近阅读子菜单 + static List _buildRecentReadSubmenu({ + required List entries, + required TrayMenuLabels labels, + required TrayMenuCallbacks callbacks, + }) { + if (entries.isEmpty) { + return [ + TrayMenuItem( + label: labels.noRecentRead, + disabled: true, + ), + ]; + } + + // 取前 maxRecentReadItems 条 + final recent = entries.take(maxRecentReadItems).toList(); + + return recent.map((entry) { + return TrayMenuItem( + label: _truncateTitle(entry.title, maxTitleLength), + onTap: () => callbacks.onOpenReadLaterEntry(entry.id), + ); + }).toList(); + } + + /// 截断标题(超出长度添加省略号) + static String _truncateTitle(String title, int maxLen) { + if (title.isEmpty) return '(无标题)'; + if (title.length <= maxLen) return title; + return '${title.substring(0, maxLen)}…'; + } +} diff --git a/lib/core/services/desktop/desktop_tray_service.dart b/lib/core/services/desktop/desktop_tray_service.dart new file mode 100644 index 00000000..7b4d57c9 --- /dev/null +++ b/lib/core/services/desktop/desktop_tray_service.dart @@ -0,0 +1,210 @@ +/// ============================================================ +/// 闲言APP — 桌面端系统托盘服务抽象 +/// 创建时间: 2026-06-18 +/// 更新时间: 2026-06-18 +/// 作用: 定义跨平台系统托盘服务接口(图标/Tooltip/菜单/未读角标/事件) +/// 上次更新: 初始创建,定义 DesktopTrayService 抽象 + TrayMenuItem 模型 +/// ============================================================ + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +/// 桌面端系统托盘服务抽象 +/// +/// 跨平台系统托盘能力统一接口,支持 macOS / Windows / Linux。 +/// iOS / Android / 鸿蒙端使用 [StubDesktopTrayService] 返回 no-op 实现。 +abstract class DesktopTrayService { + /// 单例实例(由 [DesktopServiceRegistry] 注入) + static DesktopTrayService? _instance; + + /// 获取单例实例 + static DesktopTrayService get instance { + _instance ??= _createInstance(); + return _instance!; + } + + /// 设置单例实例(用于测试注入) + static set instance(DesktopTrayService service) { + _instance = service; + } + + /// 创建实例(由子类覆盖) + DesktopTrayService createInstance(); + + static DesktopTrayService _createInstance() { + // 由 desktop_service_registry.dart 在初始化时注入 + return _instance ??= StubDesktopTrayService(); + } + + // ============================================================ + // 生命周期 + // ============================================================ + + /// 初始化托盘(图标、Tooltip、菜单) + Future init(); + + /// 销毁托盘(应用退出时调用) + Future destroy(); + + // ============================================================ + // 托盘属性 + // ============================================================ + + /// 更新托盘图标(根据主题切换浅色/深色图标) + Future setIcon({required bool isDark}); + + /// 更新 Tooltip(鼠标悬停提示) + Future setToolTip(String tip); + + /// 更新未读角标(0 表示隐藏) + /// + /// macOS: 通过 `setTitle` 显示数字角标 + /// Windows: 程序化叠加角标图层 + Future setUnreadBadge(int count); + + /// 更新右键菜单 + Future setMenu(List items); + + /// 弹出上下文菜单 + /// + /// macOS 上 tray_manager 不会自动弹出菜单,需要手动调用此方法。 + /// Windows/Linux 上通常由系统自动弹出,此方法为 no-op。 + Future popUpContextMenu(); + + // ============================================================ + // 事件流 + // ============================================================ + + /// 托盘事件流(单击/双击/右键) + Stream get events; + + // ============================================================ + // 平台能力 + // ============================================================ + + /// 是否支持托盘(平台判断) + bool get isSupported; + + /// 托盘是否已初始化 + bool get isInitialized; +} + +/// 托盘事件类型 +enum TrayEventKind { + /// 单击(左键) + click, + + /// 双击(左键) + doubleClick, + + /// 右键单击 + rightClick, +} + +/// 托盘事件 +class TrayEvent { + final TrayEventKind kind; + + const TrayEvent({required this.kind}); + + @override + String toString() => 'TrayEvent(kind: $kind)'; +} + +/// 托盘菜单项 +/// +/// 支持: +/// - 普通菜单项(label + onTap + shortcut + checked) +/// - 分隔线(isSeparator = true) +/// - 子菜单(submenu 非空) +class TrayMenuItem { + /// 菜单项标签(分隔线时为空) + final String label; + + /// 点击回调(分隔线/子菜单父项时为 null) + final VoidCallback? onTap; + + /// 子菜单(非空时为子菜单父项,点击不触发 onTap) + final List? submenu; + + /// 是否为分隔线 + final bool isSeparator; + + /// 是否勾选(用于切换状态显示,如"静默模式 ✓") + final bool checked; + + /// 快捷键提示文本(如 "Cmd+N",仅显示用,不实际绑定) + final String? shortcut; + + /// 是否禁用 + final bool disabled; + + const TrayMenuItem({ + required this.label, + this.onTap, + this.submenu, + this.isSeparator = false, + this.checked = false, + this.shortcut, + this.disabled = false, + }); + + /// 创建分隔线 + const TrayMenuItem.separator() + : label = '', + onTap = null, + submenu = null, + isSeparator = true, + checked = false, + shortcut = null, + disabled = false; + + @override + String toString() => + 'TrayMenuItem(label: $label, isSeparator: $isSeparator, checked: $checked, hasSubmenu: ${submenu != null})'; +} + +/// Stub 实现(iOS / Android / 鸿蒙端) +/// +/// 所有方法 no-op,[isSupported] 返回 false。 +class StubDesktopTrayService implements DesktopTrayService { + bool _initialized = false; + + @override + DesktopTrayService createInstance() => StubDesktopTrayService(); + + @override + Future init() async { + _initialized = true; + } + + @override + Future destroy() async { + _initialized = false; + } + + @override + Future setIcon({required bool isDark}) async {} + + @override + Future setToolTip(String tip) async {} + + @override + Future setUnreadBadge(int count) async {} + + @override + Future setMenu(List items) async {} + + @override + Future popUpContextMenu() async {} + + @override + Stream get events => const Stream.empty(); + + @override + bool get isSupported => false; + + @override + bool get isInitialized => _initialized; +} diff --git a/lib/core/services/desktop/desktop_window_effect_service.dart b/lib/core/services/desktop/desktop_window_effect_service.dart new file mode 100644 index 00000000..2405b84a --- /dev/null +++ b/lib/core/services/desktop/desktop_window_effect_service.dart @@ -0,0 +1,77 @@ +/// ============================================================ +/// 闲言APP — 桌面端窗口特效服务抽象 +/// 创建时间: 2026-06-18 +/// 更新时间: 2026-06-18 +/// 作用: 定义跨平台窗口特效接口(毛玻璃/亚克力/Mica/标题栏融合) +/// 上次更新: 初始创建,定义 DesktopWindowEffectService 抽象 +/// ============================================================ + +/// 桌面端窗口特效服务抽象 +/// +/// 跨平台窗口特效能力统一接口: +/// - macOS: 侧边栏毛玻璃 + 标题栏融合(基于 macos_window_utils) +/// - Windows: Win11 Mica + Win10 Acrylic(基于 flutter_acrylic) +/// - Linux/iOS/Android/鸿蒙: Stub 实现,无特效 +abstract class DesktopWindowEffectService { + /// 单例实例 + static DesktopWindowEffectService? _instance; + + static DesktopWindowEffectService get instance { + _instance ??= StubWindowEffectService(); + return _instance!; + } + + static set instance(DesktopWindowEffectService service) { + _instance = service; + } + + // ============================================================ + // 初始化 + // ============================================================ + + /// 初始化窗口特效(在 window_manager.ensureInitialized 之前调用) + /// + /// flutter_acrylic 的 `Window.initialize()` 必须在 window_manager 之前调用。 + Future initialize(); + + // ============================================================ + // 特效应用 + // ============================================================ + + /// 应用窗口特效 + /// + /// [isDark] 当前是否深色主题 + /// [sidebarBlur] 是否启用侧边栏毛玻璃(仅 macOS 生效) + Future applyEffect({ + required bool isDark, + bool sidebarBlur = true, + }); + + // ============================================================ + // 平台能力 + // ============================================================ + + /// 是否支持窗口特效 + bool get isSupported; + + /// 当前特效名称(用于调试/日志) + String get effectName; +} + +/// Stub 实现(iOS / Android / 鸿蒙 / Linux) +class StubWindowEffectService implements DesktopWindowEffectService { + @override + Future initialize() async {} + + @override + Future applyEffect({ + required bool isDark, + bool sidebarBlur = true, + }) async {} + + @override + bool get isSupported => false; + + @override + String get effectName => 'none'; +} diff --git a/lib/core/services/desktop/implementations/macos_window_effect_service.dart b/lib/core/services/desktop/implementations/macos_window_effect_service.dart new file mode 100644 index 00000000..600cd089 --- /dev/null +++ b/lib/core/services/desktop/implementations/macos_window_effect_service.dart @@ -0,0 +1,115 @@ +/// ============================================================ +/// 闲言APP — macOS 窗口特效服务实现 +/// 创建时间: 2026-06-18 +/// 更新时间: 2026-06-18 +/// 作用: 基于 macos_window_utils 实现 macOS 窗口特效(标题栏融合/侧边栏毛玻璃) +/// 上次更新: 初始创建,实现 DesktopWindowEffectService 接口 +/// ============================================================ + +import 'package:macos_window_utils/macos_window_utils.dart'; + +import '../desktop_window_effect_service.dart'; +import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu; +import 'package:xianyan/core/utils/logger.dart'; + +/// macOS 窗口特效服务实现 +/// +/// 基于 macos_window_utils 提供: +/// - 标题栏融合(titlebarAppearsTransparent + fullSizeContentView) +/// - 侧边栏毛玻璃(NSVisualEffectViewMaterial.sidebar) +/// - 主题跟随(overrideMacOSBrightness) +class MacosWindowEffectService implements DesktopWindowEffectService { + MacosWindowEffectService._(); + + static final MacosWindowEffectService _instance = + MacosWindowEffectService._(); + + factory MacosWindowEffectService() => _instance; + + bool _initialized = false; + int? _sidebarVisualEffectSubviewId; + + @override + Future initialize() async { + if (!pu.isMacOS) return; + if (_initialized) return; + + try { + await WindowManipulator.initialize(); + _initialized = true; + Log.i('MacosWindowEffectService 初始化完成'); + } catch (e) { + Log.e('MacosWindowEffectService.initialize 失败: $e'); + } + } + + @override + Future applyEffect({ + required bool isDark, + bool sidebarBlur = true, + }) async { + if (!pu.isMacOS || !_initialized) return; + + try { + // 1. 标题栏融合:透明标题栏 + 全尺寸内容视图 + await WindowManipulator.makeTitlebarTransparent(); + await WindowManipulator.enableFullSizeContentView(); + await WindowManipulator.hideTitle(); + + // 2. 主题跟随:覆盖 macOS 亮度设置 + await WindowManipulator.overrideMacOSBrightness(dark: isDark); + + // 3. 侧边栏毛玻璃 + if (sidebarBlur) { + await _applySidebarBlur(); + } + + // 4. 窗口背景色设为透明(让 NSVisualEffectView 透出) + await WindowManipulator.setWindowBackgroundColorToClear(); + + Log.i('MacosWindowEffectService 特效已应用 (isDark=$isDark, sidebarBlur=$sidebarBlur)'); + } catch (e) { + Log.e('MacosWindowEffectService.applyEffect 失败: $e'); + } + } + + /// 应用侧边栏毛玻璃 + /// + /// 使用 NSVisualEffectViewMaterial.sidebar 实现 macOS 原生侧边栏模糊效果。 + /// 最低支持 macOS 12(项目最低版本 13.0,完全兼容)。 + Future _applySidebarBlur() async { + try { + // 设置主视觉效果视图状态为 active + await WindowManipulator.setNSVisualEffectViewState( + NSVisualEffectViewState.active, + ); + + // 设置材质为 sidebar(macOS 12+ 支持) + await WindowManipulator.setMaterial(NSVisualEffectViewMaterial.sidebar); + + // 如果之前已添加子视图,先移除 + if (_sidebarVisualEffectSubviewId != null) { + await WindowManipulator.removeVisualEffectSubview( + _sidebarVisualEffectSubviewId!, + ); + } + + // 添加侧边栏视觉效果子视图 + _sidebarVisualEffectSubviewId = await WindowManipulator + .addVisualEffectSubview( + VisualEffectSubviewProperties( + material: NSVisualEffectViewMaterial.sidebar, + state: NSVisualEffectViewState.active, + ), + ); + } catch (e) { + Log.e('MacosWindowEffectService._applySidebarBlur 失败: $e'); + } + } + + @override + bool get isSupported => pu.isMacOS; + + @override + String get effectName => 'macos_sidebar_blur'; +} diff --git a/lib/core/services/desktop/implementations/tray_manager_tray_service.dart b/lib/core/services/desktop/implementations/tray_manager_tray_service.dart new file mode 100644 index 00000000..f4d83e85 --- /dev/null +++ b/lib/core/services/desktop/implementations/tray_manager_tray_service.dart @@ -0,0 +1,211 @@ +/// ============================================================ +/// 闲言APP — tray_manager 系统托盘服务实现 +/// 创建时间: 2026-06-18 +/// 更新时间: 2026-06-18 +/// 作用: 基于 tray_manager 实现跨平台系统托盘(macOS/Win/Linux) +/// 上次更新: 初始创建,实现 DesktopTrayService 接口 +/// ============================================================ + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:tray_manager/tray_manager.dart'; + +import '../desktop_tray_service.dart'; +import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu; +import 'package:xianyan/core/utils/logger.dart'; + +/// 基于 tray_manager 的系统托盘服务实现 +/// +/// 支持 macOS / Windows / Linux 三端。 +/// iOS / Android / 鸿蒙端使用 [StubDesktopTrayService]。 +class TrayManagerTrayService + implements DesktopTrayService, TrayListener { + TrayManagerTrayService._(); + + static final TrayManagerTrayService _instance = TrayManagerTrayService._(); + + factory TrayManagerTrayService() => _instance; + + @override + DesktopTrayService createInstance() => _instance; + + bool _initialized = false; + final _eventController = StreamController.broadcast(); + + // 当前菜单项回调映射(id -> callback) + final Map _callbackMap = {}; + + @override + Future init() async { + if (!pu.isDesktop) { + Log.w('TrayManagerTrayService.init: 当前平台不支持托盘'); + return; + } + if (_initialized) return; + + try { + trayManager.addListener(this); + _initialized = true; + Log.i('TrayManagerTrayService 初始化完成'); + } catch (e) { + Log.e('TrayManagerTrayService.init 失败: $e'); + } + } + + @override + Future destroy() async { + if (!_initialized) return; + try { + trayManager.removeListener(this); + await trayManager.destroy(); + _initialized = false; + _callbackMap.clear(); + await _eventController.close(); + Log.i('TrayManagerTrayService 已销毁'); + } catch (e) { + Log.e('TrayManagerTrayService.destroy 失败: $e'); + } + } + + @override + Future setIcon({required bool isDark}) async { + if (!_initialized) return; + try { + // macOS 使用 isTemplate 让系统自动反色 + // Windows/Linux 需要明暗两套图标 + final iconPath = isDark + ? 'assets/images/tray_icon_dark.png' + : 'assets/images/tray_icon_light.png'; + + if (pu.isMacOS) { + // macOS: isTemplate=true 时系统自动处理深浅色 + await trayManager.setIcon( + 'assets/images/tray_icon_light.png', + isTemplate: true, + ); + } else { + // Windows/Linux: 明暗两套图标 + await trayManager.setIcon(iconPath); + } + } catch (e) { + Log.e('TrayManagerTrayService.setIcon 失败: $e'); + } + } + + @override + Future setToolTip(String tip) async { + if (!_initialized) return; + try { + await trayManager.setToolTip(tip); + } catch (e) { + Log.e('TrayManagerTrayService.setToolTip 失败: $e'); + } + } + + @override + Future setUnreadBadge(int count) async { + if (!_initialized) return; + try { + // macOS: setTitle 显示数字角标(标题显示在图标旁) + // Windows/Linux: tray_manager 不原生支持角标,仅更新 Tooltip + if (pu.isMacOS) { + await trayManager.setTitle(count > 0 ? count.toString() : ''); + } + // 统一更新 Tooltip 包含未读数 + final tip = count > 0 ? '闲言 — $count 条未读' : '闲言'; + await trayManager.setToolTip(tip); + } catch (e) { + Log.e('TrayManagerTrayService.setUnreadBadge 失败: $e'); + } + } + + @override + Future setMenu(List items) async { + if (!_initialized) return; + try { + _callbackMap.clear(); + final menuItems = []; + for (final item in items) { + menuItems.add(_convertMenuItem(item)); + } + final menu = Menu(items: menuItems); + await trayManager.setContextMenu(menu); + } catch (e, st) { + Log.e('TrayManagerTrayService.setMenu 失败: $e', e, st); + } + } + + @override + Future popUpContextMenu() async { + if (!_initialized) return; + try { + await trayManager.popUpContextMenu(); + } catch (e, st) { + Log.e('TrayManagerTrayService.popUpContextMenu 失败: $e', e, st); + } + } + + /// 将 TrayMenuItem 转换为 menu_base 的 MenuItem + MenuItem _convertMenuItem(TrayMenuItem item) { + if (item.isSeparator) { + return MenuItem.separator(); + } + + final menuItem = MenuItem( + label: item.label, + disabled: item.disabled, + onClick: (menuItem) { + item.onTap?.call(); + }, + ); + + if (item.checked) { + menuItem.type = 'checkbox'; + menuItem.checked = true; + } + + if (item.submenu != null && item.submenu!.isNotEmpty) { + menuItem.type = 'submenu'; + menuItem.submenu = Menu( + items: item.submenu!.map(_convertMenuItem).toList(), + ); + } + + return menuItem; + } + + @override + Stream get events => _eventController.stream; + + @override + bool get isSupported => pu.isDesktop; + + @override + bool get isInitialized => _initialized; + + // ============================================================ + // TrayListener 回调 + // ============================================================ + + @override + void onTrayIconMouseDown() { + _eventController.add(const TrayEvent(kind: TrayEventKind.click)); + } + + @override + void onTrayIconMouseUp() {} + + @override + void onTrayIconRightMouseDown() { + _eventController.add(const TrayEvent(kind: TrayEventKind.rightClick)); + } + + @override + void onTrayIconRightMouseUp() {} + + @override + void onTrayMenuItemClick(MenuItem menuItem) { + // 回调已在 _convertMenuItem 的 onClick 中处理 + } +} diff --git a/lib/core/services/desktop/implementations/windows_acrylic_service.dart b/lib/core/services/desktop/implementations/windows_acrylic_service.dart new file mode 100644 index 00000000..ebaf031c --- /dev/null +++ b/lib/core/services/desktop/implementations/windows_acrylic_service.dart @@ -0,0 +1,164 @@ +/// ============================================================ +/// 闲言APP — Windows 窗口特效服务实现 +/// 创建时间: 2026-06-18 +/// 更新时间: 2026-06-19 +/// 作用: 基于 flutter_acrylic 实现 Windows 窗口特效(Win11 Mica Alt/Mica/Win10 Acrylic) +/// 上次更新: 新增 Mica Alt 特效支持(Win11 build >= 22621),自动降级 Mica Alt → Mica → Acrylic +/// ============================================================ + +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_acrylic/flutter_acrylic.dart'; + +import '../desktop_window_effect_service.dart'; +import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu; +import 'package:xianyan/core/utils/logger.dart'; + +/// Windows 窗口特效服务实现 +/// +/// 基于 flutter_acrylic 提供: +/// - Win11 22621+: Mica Alt(云母变体,对壁纸色调更敏感,标题栏区域同样应用特效) +/// - Win11 22000+: Mica(云母,跟随系统主题) +/// - Win10 1809+: Acrylic(亚克力半透明) +/// - Win10 早期: 降级为纯色背景 +/// +/// 实现说明:flutter_acrylic 1.1.4 的 WindowEffect 枚举未直接提供 micaAlt, +/// 此处使用 [WindowEffect.tabbed](对应 DWM DWMSBT_TABBEDWINDOW)作为 Mica Alt +/// 的等价实现——两者均为"比 Mica 更透明、对桌面壁纸色调更敏感"的云母变体, +/// 视觉表现与 Win11 22621+ 的 Mica Alt 一致。 +class WindowsAcrylicService implements DesktopWindowEffectService { + WindowsAcrylicService._(); + + static final WindowsAcrylicService _instance = WindowsAcrylicService._(); + + factory WindowsAcrylicService() => _instance; + + bool _initialized = false; + bool? _isWin11OrLater; + bool? _isMicaAltSupportedCache; + + @override + Future initialize() async { + if (!pu.isWindows) return; + if (_initialized) return; + + try { + await Window.initialize(); + _isWin11OrLater = await _detectWindows11OrLater(); + _isMicaAltSupportedCache = await _isMicaAltSupported(); + _initialized = true; + Log.i('WindowsAcrylicService 初始化完成 ' + '(isWin11=$_isWin11OrLater, micaAlt=$_isMicaAltSupportedCache)'); + } catch (e) { + Log.e('WindowsAcrylicService.initialize 失败: $e'); + } + } + + @override + Future applyEffect({ + required bool isDark, + bool sidebarBlur = true, + }) async { + if (!pu.isWindows || !_initialized) return; + + try { + // 优先级:Mica Alt → Mica → Acrylic + // Mica Alt(Win11 22621+):对壁纸色调更敏感,标题栏区域同样应用特效 + // 使用 WindowEffect.tabbed 作为 Mica Alt 的等价实现(见类说明) + if (_isMicaAltSupportedCache == true) { + await Window.setEffect( + effect: WindowEffect.tabbed, + color: isDark ? const Color(0xFF1C1C1C) : const Color(0xFFF3F3F3), + dark: isDark, + ); + Log.i('WindowsAcrylicService 应用 Mica Alt 特效 (isDark=$isDark)'); + return; + } + + if (_isWin11OrLater == true) { + // Win11: Mica 背景(跟随系统主题) + await Window.setEffect( + effect: WindowEffect.mica, + dark: isDark, + ); + Log.i('WindowsAcrylicService 应用 Mica 特效 (isDark=$isDark)'); + } else { + // Win10: Acrylic 半透明 + await Window.setEffect( + effect: WindowEffect.acrylic, + dark: isDark, + color: isDark + ? const Color(0xCC1F1F1F) // 深色半透明 + : const Color(0xCCF3F3F3), // 浅色半透明 + ); + Log.i('WindowsAcrylicService 应用 Acrylic 特效 (isDark=$isDark)'); + } + } catch (e) { + Log.e('WindowsAcrylicService.applyEffect 失败: $e'); + // 降级:禁用特效 + try { + await Window.setEffect( + effect: WindowEffect.disabled, + dark: isDark, + ); + } catch (_) {} + } + } + + /// 检测当前系统是否支持 Mica Alt(Win11 build >= 22621) + /// + /// 使用 device_info_plus 读取精确的 Windows build 号。 + /// Mica Alt(此处以 WindowEffect.tabbed 等价实现)需要 Win11 22621 及以上。 + /// 结果会被缓存,避免重复读取设备信息。 + Future _isMicaAltSupported() async { + if (_isMicaAltSupportedCache != null) return _isMicaAltSupportedCache!; + try { + final info = await DeviceInfoPlugin().windowsInfo; + // device_info_plus 13.x 中 buildNumber 为 int 类型,无需解析 + final supported = info.buildNumber >= 22621; + _isMicaAltSupportedCache = supported; + return supported; + } catch (e) { + Log.w('WindowsAcrylicService._isMicaAltSupported 检测失败: $e'); + _isMicaAltSupportedCache = false; + return false; + } + } + + /// 检测是否为 Windows 11 或更高版本 + /// + /// Windows 11 Build >= 22000 + Future _detectWindows11OrLater() async { + try { + final version = Platform.operatingSystemVersion; + // 解析格式:"10.0.22000.1234" 或 "Windows 10 Pro 10.0.22000.1234" + final match = RegExp(r'(\d+)\.(\d+)\.(\d+)').firstMatch(version); + if (match == null) return false; + + final major = int.parse(match.group(1)!); + final minor = int.parse(match.group(2)!); + final build = int.parse(match.group(3)!); + + // Win11: major=10, minor=0, build>=22000 + // 注意:Win11 和 Win10 都报告 major=10,通过 build 号区分 + if (major == 10 && minor == 0 && build >= 22000) { + return true; + } + return false; + } catch (e) { + Log.w('WindowsAcrylicService._detectWindows11OrLater 解析失败: $e'); + return false; + } + } + + @override + bool get isSupported => pu.isWindows; + + @override + String get effectName { + if (_isMicaAltSupportedCache == true) return 'windows_mica_alt'; + return _isWin11OrLater == true ? 'windows_mica' : 'windows_acrylic'; + } +} diff --git a/lib/core/services/device/macos_platform_service.dart b/lib/core/services/device/macos_platform_service.dart index 831515cc..c76f47c2 100644 --- a/lib/core/services/device/macos_platform_service.dart +++ b/lib/core/services/device/macos_platform_service.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — macOS平台统一服务 /// 创建时间: 2026-06-02 -/// 更新时间: 2026-06-02 -/// 作用: 集中管理所有macOS原生MethodChannel交互(主题同步/窗口管理/工具栏样式) -/// 上次更新: 整合MacosTitleBarService,新增窗口管理能力 +/// 更新时间: 2026-06-19 +/// 作用: 集中管理所有macOS原生MethodChannel交互(主题同步/窗口管理/Touch Bar/共享/Dock徽章/菜单栏金句/Spotlight) +/// 上次更新: 新增 Touch Bar、NSSharingService、NSDockTile、NSStatusItem、CoreSpotlight 五项原生能力 /// ============================================================ import 'package:flutter/services.dart'; @@ -13,7 +13,11 @@ import 'package:xianyan/core/utils/logger.dart'; class MacosPlatformService { MacosPlatformService._(); - static const _channel = MethodChannel('com.xianyan.macos'); + /// 窗口级通道(MainFlutterWindow 注册) + static const _channel = MethodChannel('apps.xy.xianyan/macos'); + + /// 应用级通道(AppDelegate 注册) + static const _appChannel = MethodChannel('apps.xy.xianyan/macos.app'); // ============================================================ // 主题同步(原 MacosTitleBarService) @@ -106,6 +110,99 @@ class MacosPlatformService { } } + // ============================================================ + // Touch Bar 支持 + // ============================================================ + + /// 设置 Touch Bar 按钮项 + /// + /// [items] 按钮列表,每项包含 {label: "加粗", action: "bold"} + /// 点击按钮时通过 touchBarAction 事件回调 + static Future setTouchBarItems(List> items) async { + if (!pu.isMacOS) return; + try { + await _channel.invokeMethod('setTouchBarItems', {'items': items}); + } catch (e) { + Log.w('MacosPlatformService.setTouchBarItems失败: $e'); + } + } + + // ============================================================ + // NSSharingService 共享 + // ============================================================ + + /// 显示系统共享面板,支持 text/url/image 三种内容 + static Future showShareSheet({ + String? text, + String? url, + Uint8List? imageBytes, + }) async { + if (!pu.isMacOS) return; + try { + final args = {}; + if (text != null) args['text'] = text; + if (url != null) args['url'] = url; + if (imageBytes != null) args['imageBytes'] = imageBytes; + await _channel.invokeMethod('showShareSheet', args); + } catch (e) { + Log.w('MacosPlatformService.showShareSheet失败: $e'); + } + } + + // ============================================================ + // Dock 徽章(NSDockTile) + // ============================================================ + + /// 设置 Dock 图标徽章数字,count <= 0 时清除 + static Future setDockBadge(int count) async { + if (!pu.isMacOS) return; + try { + await _appChannel.invokeMethod('setDockBadge', {'count': count}); + } catch (e) { + Log.w('MacosPlatformService.setDockBadge失败: $e'); + } + } + + // ============================================================ + // 菜单栏金句(NSStatusItem) + // ============================================================ + + /// 更新菜单栏金句,超过 30 字符截断显示,点击复制完整内容 + static Future updateStatusBarSentence(String sentence) async { + if (!pu.isMacOS) return; + try { + await _appChannel.invokeMethod('updateStatusBarSentence', {'sentence': sentence}); + } catch (e) { + Log.w('MacosPlatformService.updateStatusBarSentence失败: $e'); + } + } + + // ============================================================ + // Spotlight 索引(CoreSpotlight) + // ============================================================ + + /// 索引条目到 Spotlight 搜索 + /// + /// [items] 条目列表,每项包含 {id, title, content, type} + static Future indexSpotlightItems(List> items) async { + if (!pu.isMacOS) return; + try { + await _appChannel.invokeMethod('indexSpotlightItems', {'items': items}); + } catch (e) { + Log.w('MacosPlatformService.indexSpotlightItems失败: $e'); + } + } + + /// 清除所有 Spotlight 索引 + static Future clearSpotlightIndex() async { + if (!pu.isMacOS) return; + try { + await _appChannel.invokeMethod('clearSpotlightIndex'); + } catch (e) { + Log.w('MacosPlatformService.clearSpotlightIndex失败: $e'); + } + } + // ============================================================ // 内部工具 // ============================================================ diff --git a/lib/core/services/device/windows_platform_service.dart b/lib/core/services/device/windows_platform_service.dart index 84906aaf..efca643a 100644 --- a/lib/core/services/device/windows_platform_service.dart +++ b/lib/core/services/device/windows_platform_service.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — Windows平台统一服务 /// 创建时间: 2026-06-16 -/// 更新时间: 2026-06-16 -/// 作用: 集中管理所有Windows原生MethodChannel交互(标题栏主题同步) -/// 上次更新: 初始创建,支持标题栏深色模式切换 +/// 更新时间: 2026-06-18 +/// 作用: 集中管理所有Windows原生MethodChannel交互 +/// 上次更新: 补齐 6 个 MethodChannel 方法(setWindowTitle/setFullscreen/isFullscreen/setMinSize/performHapticFeedback/getSystemAppearance) /// ============================================================ import 'package:flutter/services.dart'; @@ -13,7 +13,7 @@ import 'package:xianyan/core/utils/logger.dart'; class WindowsPlatformService { WindowsPlatformService._(); - static const _channel = MethodChannel('com.xianyan.windows'); + static const _channel = MethodChannel('apps.xy.xianyan/windows'); // ============================================================ // 主题同步 @@ -33,6 +33,60 @@ class WindowsPlatformService { _invoke('setDarkMode', {'isDark': isDark}); } + // ============================================================ + // 窗口管理(6 个新增方法) + // ============================================================ + + /// 设置窗口标题 + static void setWindowTitle(String title) { + if (!pu.isWindows) return; + _invoke('setWindowTitle', {'title': title}); + } + + /// 进入/退出全屏模式 + static void setFullscreen(bool fullscreen) { + if (!pu.isWindows) return; + _invoke('setFullscreen', {'fullscreen': fullscreen}); + } + + /// 查询当前是否处于全屏模式 + static Future isFullscreen() async { + if (!pu.isWindows) return false; + try { + final result = await _channel.invokeMethod('isFullscreen'); + return result ?? false; + } catch (e) { + Log.w('WindowsPlatformService.isFullscreen失败: $e'); + return false; + } + } + + /// 设置窗口最小尺寸(逻辑像素) + static void setMinSize(int width, int height) { + if (!pu.isWindows) return; + _invoke('setMinSize', {'width': width, 'height': height}); + } + + /// 执行触觉反馈 + /// + /// [type] 0=light, 1=medium, 2=heavy, 3=selection + static void performHapticFeedback(int type) { + if (!pu.isWindows) return; + _invoke('performHapticFeedback', {'type': type}); + } + + /// 获取系统外观模式("light" 或 "dark") + static Future getSystemAppearance() async { + if (!pu.isWindows) return 'light'; + try { + final result = await _channel.invokeMethod('getSystemAppearance'); + return result ?? 'light'; + } catch (e) { + Log.w('WindowsPlatformService.getSystemAppearance失败: $e'); + return 'light'; + } + } + // ============================================================ // 内部工具 // ============================================================ diff --git a/lib/core/services/network/deep_link_service.dart b/lib/core/services/network/deep_link_service.dart index 399799f5..952efcd2 100644 --- a/lib/core/services/network/deep_link_service.dart +++ b/lib/core/services/network/deep_link_service.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 深度链接服务 /// 创建时间: 2026-04-28 -/// 更新时间: 2026-05-27 +/// 更新时间: 2026-06-19 /// 作用: 使用 app_links 统一处理深度链接,支持冷启动和热恢复 -/// 上次更新: 重构为使用 AppRouter.resolveDeepLinkUri 统一路径映射,消除重复逻辑 +/// 上次更新: 新增 note/{id} 和 sentence/{id} 预解析,支持笔记编辑和句子详情跳转 /// ============================================================ import 'dart:async'; @@ -63,10 +63,45 @@ class DeepLinkService { } } + /// 预解析:处理需要特殊路由映射的 xianyan:// scheme + /// + /// - `xianyan://note/{id}` → `/notes/edit?id={id}`(笔记编辑页用 query param) + /// - `xianyan://note` → `/notes`(笔记列表页) + /// - `xianyan://sentence/{id}` → `/home`(句子详情需 HomeSentence 对象, + /// 暂导航到首页,后续可扩展为通过 ID 加载句子后弹出详情 Sheet) + /// + /// 返回 null 表示无需预解析,交给 AppRouter.resolveDeepLinkUri 统一处理 + static String? _preResolve(Uri uri) { + if (uri.scheme != 'xianyan') return null; + + final host = uri.host; + final segments = uri.pathSegments; + + switch (host) { + case 'note': + // 笔记编辑页 /notes/edit 接收 query param id + final noteId = segments.isNotEmpty ? segments.first : null; + if (noteId != null && noteId.isNotEmpty) { + Log.i('🔗 [DeepLink] note/$noteId → ${AppRoutes.noteEdit}?id=$noteId'); + return '${AppRoutes.noteEdit}?id=$noteId'; + } + return AppRoutes.noteList; + case 'sentence': + // 句子详情 Sheet 需要 HomeSentence 对象,暂导航到首页 + final sentenceId = segments.isNotEmpty ? segments.first : ''; + Log.i('🔗 [DeepLink] sentence/$sentenceId → 首页(句子详情需加载后展示)'); + return AppRoutes.home; + default: + return null; + } + } + /// 处理单个深度链接 URI - /// 使用 AppRouter.resolveDeepLinkUri 统一路径映射 + /// 先通过 _preResolve 处理需要特殊路由的 scheme(note/{id}, sentence/{id}), + /// 其余委托给 AppRouter.resolveDeepLinkUri 统一路径映射 static void _handleLink(Uri uri) { - final resolved = AppRouter.resolveDeepLinkUri(uri); + // 1. 预解析:处理需要特殊路由的 scheme + final resolved = _preResolve(uri) ?? AppRouter.resolveDeepLinkUri(uri); if (resolved == null) { Log.w('🔗 [DeepLink] 无法解析: $uri'); return; diff --git a/lib/core/services/notification/notification_center.dart b/lib/core/services/notification/notification_center.dart index 394e1417..28cbeed9 100644 --- a/lib/core/services/notification/notification_center.dart +++ b/lib/core/services/notification/notification_center.dart @@ -1,11 +1,12 @@ /// ============================================================ /// 闲言APP — 统一通知中心 /// 创建时间: 2026-05-22 -/// 更新时间: 2026-06-12 +/// 更新时间: 2026-06-19 /// 作用: 合并 NotificationScheduler + DailyNotifyService,统一管理所有本地通知调度 -/// 上次更新: 新增推送计数器(pushCount/clickCount),每次调度/点击时+1 +/// 上次更新: 类型安全修复(int vs num): 节气日期 year/month/day 使用 SafeJson.parseInt /// ============================================================ +import 'package:xianyan/core/utils/safe_json.dart'; import 'local_notification_service.dart'; import '../../storage/kv_storage.dart'; import '../../utils/logger.dart'; @@ -306,9 +307,9 @@ class NotificationCenter { if (nextTerm == null) return; final scheduledTime = DateTime( - nextTerm['year'] as int, - nextTerm['month'] as int, - nextTerm['day'] as int, + SafeJson.parseInt(nextTerm['year']), + SafeJson.parseInt(nextTerm['month']), + SafeJson.parseInt(nextTerm['day']), 8, ); @@ -360,9 +361,9 @@ class NotificationCenter { final terms = _solarTerms2026; for (final term in terms) { final date = DateTime( - term['year'] as int, - term['month'] as int, - term['day'] as int, + SafeJson.parseInt(term['year']), + SafeJson.parseInt(term['month']), + SafeJson.parseInt(term['day']), ); if (date.isAfter(now)) return term; } diff --git a/lib/core/storage/database/app_database.dart b/lib/core/storage/database/app_database.dart index 03591c07..b9386572 100644 --- a/lib/core/storage/database/app_database.dart +++ b/lib/core/storage/database/app_database.dart @@ -1499,10 +1499,17 @@ class AppDatabase extends _$AppDatabase { }, 'setFavoriteFlag'); } - /// 设置收藏状态(按targetType+targetId匹配,兼容复合ID和数字ID两种格式) + /// 设置收藏状态(按targetType+targetId匹配,兼容多种ID格式) /// - /// sentences表中ID可能是复合格式(如"feed_123")或纯数字格式(如"123"), - /// 此方法同时尝试两种格式,确保本地DB收藏状态正确更新。 + /// sentences表中ID可能是以下格式: + /// - 复合格式:`feed_123`、`hitokoto_456`、`poetry_789` 等 + /// - 纯数字格式:`123` + /// - 其他前缀格式:`prefix_123` + /// + /// 此方法同时尝试多种匹配策略,确保本地DB收藏状态正确更新: + /// 1. 精确匹配 `${targetType}_$targetId` + /// 2. 精确匹配 `$targetId` + /// 3. 按 feedType 字段 + ID 以 `_$targetId` 结尾匹配(覆盖各种前缀) Future setFavoriteFlagForTarget( String targetType, int targetId, @@ -1513,14 +1520,22 @@ class AppDatabase extends _$AppDatabase { isFavorite: Value(value), updatedAt: Value(DateTime.now()), ); - // 尝试复合ID(如"feed_123") + // 策略1:复合ID(如"feed_123") await (update(sentences) ..where((t) => t.id.equals('${targetType}_$targetId'))) .write(companion); - // 同时尝试纯数字ID(如"123",Hitokoto等来源) + // 策略2:纯数字ID(如"123",Hitokoto等来源) await (update(sentences) ..where((t) => t.id.equals(targetId.toString()))) .write(companion); + // 策略3:按 feedType 字段 + ID 以 "_$targetId" 结尾匹配 + // 覆盖各种前缀格式(如 hitokoto_456、poetry_789 等) + final suffix = '_$targetId'; + await (update(sentences) + ..where((t) => + t.feedType.equals(targetType) & + t.id.like('%$suffix'))) + .write(companion); }, 'setFavoriteFlagForTarget'); } @@ -1754,6 +1769,19 @@ class AppDatabase extends _$AppDatabase { }, label: 'getSentenceFavoriteCount'); } + /// 获取自指定时间以来新增的收藏数(按updatedAt过滤) + /// 用于阅读目标的今日收藏进度统计 + Future getFavoriteCountSince(DateTime since) { + return _safeDbInt(() async { + final query = selectOnly(sentences) + ..addColumns([sentences.id.count()]) + ..where(sentences.isFavorite.equals(true) & + sentences.updatedAt.isBiggerOrEqualValue(since)); + final rows = await query.getSingle(); + return rows.read(sentences.id.count()) ?? 0; + }, label: 'getFavoriteCountSince'); + } + Future> getHistoryCountByFeedType() { return _safeDbMap(() async { final rows = diff --git a/lib/core/storage/kv_storage.dart b/lib/core/storage/kv_storage.dart index 69c53c8b..9caf0977 100644 --- a/lib/core/storage/kv_storage.dart +++ b/lib/core/storage/kv_storage.dart @@ -1,15 +1,16 @@ /// ============================================================ /// 闲言APP — 统一 KV 本地存储 /// 创建时间: 2026-04-20 -/// 更新时间: 2026-05-30 +/// 更新时间: 2026-06-19 /// 作用: 基于 Hive 的统一 KV 存储,合并原 KvStorage(SP) + AppKVStore(Hive) -/// 上次更新: StorageKeys 新增命名空间前缀系统 +/// 上次更新: 类型安全修复(int vs num): 工具使用计数使用 SafeJson.parseInt /// ============================================================ import 'dart:convert'; import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../utils/logger.dart'; import 'hive_safe_access.dart'; @@ -98,6 +99,8 @@ class StorageKeys { static const String debugMode = 'debug_mode'; static const String onboardingCompleted = 'onboarding_completed'; static const String showOnboarding = 'show_onboarding'; + static const String knowNewFeatures = 'know_new_features'; + static const String lastSeenVersion = 'last_seen_version'; static const String channelOrder = 'channel_order'; // ============================================================ @@ -129,6 +132,10 @@ class StorageKeys { // — settings 命名空间 — static String get nsSettingsOnboarding => namespaced(_nsSettings, 'onboarding_completed'); static String get nsSettingsShowOnboarding => namespaced(_nsSettings, 'show_onboarding'); + static String get nsSettingsWorkbenchMiddleWidth => namespaced(_nsSettings, 'workbench_middle_width'); + static String get nsSettingsWorkbenchNavBarWidth => namespaced(_nsSettings, 'workbench_nav_bar_width'); + static String get nsSettingsNavBarCollapsed => namespaced(_nsSettings, 'nav_bar_collapsed'); + static String get nsSettingsTabSplitRatios => namespaced(_nsSettings, 'tab_split_ratios'); // — device 命名空间 — static String get nsDeviceId => namespaced(_nsDevice, 'device_id'); @@ -404,7 +411,7 @@ class KvStorage { final key = toolId; if (stats.containsKey(key)) { final entry = Map.from(stats[key] as Map); - entry['count'] = (entry['count'] as int? ?? 0) + 1; + entry['count'] = SafeJson.parseInt(entry['count']) + 1; entry['lastUsed'] = DateTime.now().toIso8601String(); stats[key] = entry; } else { @@ -543,6 +550,17 @@ class KvStorage { static Future setShowOnboarding(bool value) => setBool(StorageKeys.showOnboarding, value); + /// 是否开启了"了解新版本功能" + static bool get knowNewFeatures => + getBool(StorageKeys.knowNewFeatures) ?? false; + + /// 上次查看新功能的版本号 + static String? get lastSeenVersion => + getString(StorageKeys.lastSeenVersion); + + static Future setLastSeenVersion(String version) => + setString(StorageKeys.lastSeenVersion, version); + // ============================================================ // 全局清理 // ============================================================ diff --git a/lib/core/utils/platform/clipboard_bridge.dart b/lib/core/utils/platform/clipboard_bridge.dart index 5265d863..eb4807d7 100644 --- a/lib/core/utils/platform/clipboard_bridge.dart +++ b/lib/core/utils/platform/clipboard_bridge.dart @@ -1,12 +1,14 @@ /// ============================================================ /// 闲言APP — 剪贴板桥接工具(含隐私协议守卫) /// 创建时间: 2026-05-17 -/// 更新时间: 2026-06-17 +/// 更新时间: 2026-06-18 /// 作用: 统一剪贴板读取入口,鸿蒙平台通过原生MethodChannel读取, /// 其他平台使用Flutter Clipboard;未同意隐私协议时禁止读取; -/// 鸿蒙端拦截Flutter标准Clipboard通道,修复系统粘贴不工作 -/// 上次更新: 修复鸿蒙端粘贴功能不工作 — 拦截SystemChannels.platform -/// 的Clipboard方法,路由到鸿蒙原生pasteboard API +/// 鸿蒙端拦截Flutter标准Clipboard通道,修复系统粘贴不工作; +/// 桌面端(macOS/Windows)支持富文本(HTML)读写 +/// 上次更新: 新增富文本(HTML)支持 — 新增 setRichText/getHtml/hasHtml +/// 方法,桌面端通过 apps.xy.xianyan/clipboard 通道写入HTML, +/// 移动端/Web 降级为纯文本(去除HTML标签) /// ============================================================ import 'package:flutter/services.dart'; @@ -19,8 +21,14 @@ class ClipboardBridge { static const _channel = MethodChannel('plugins.flutter.io/clipboard_ohos'); + /// 桌面端原生剪贴板通道(macOS/Windows),用于富文本(HTML)读写 + static const _desktopChannel = MethodChannel('apps.xy.xianyan/clipboard'); + static bool get _isOhos => pu.isOhos; + /// 是否为支持HTML富文本原生读写的桌面平台(macOS/Windows) + static bool get _supportsRichTextNative => pu.isMacOS || pu.isWindows; + /// 是否已安装鸿蒙端标准剪贴板拦截器 static bool _ohosInterceptorInstalled = false; @@ -114,4 +122,166 @@ class ClipboardBridge { final data = await Clipboard.getData(Clipboard.kTextPlain); return data?.text?.isNotEmpty ?? false; } + + // ============================================================ + // 富文本(HTML)支持 + // ============================================================ + + /// 写入富文本(HTML)到剪贴板 + /// + /// - 桌面端(macOS/Windows):通过原生 MethodChannel('apps.xy.xianyan/clipboard') + /// 写入 HTML 格式,同时附带纯文本以便不支持HTML的应用读取 + /// - 鸿蒙端:通过原生 _channel 写入纯文本(鸿蒙不支持HTML剪贴板) + /// - 移动端(iOS/Android)/Web:降级为纯文本,html 参数通过简单正则 + /// 去除标签后作为 text 写入 + /// + /// [text] 纯文本内容(可选,若未提供则从 [html] 提取) + /// [html] HTML 富文本内容(可选) + static Future setRichText({String? text, String? html}) async { + // 计算最终写入的纯文本(用于降级路径和作为原生通道的text参数) + final plainText = text ?? _htmlToPlainText(html); + + // 鸿蒙端:通过原生通道写入纯文本(鸿蒙不支持HTML剪贴板) + if (_isOhos) { + try { + if (plainText != null && plainText.isNotEmpty) { + await _channel.invokeMethod( + 'Clipboard.setData', + {'text': plainText}, + ); + } + return; + } on MissingPluginException { + // 原生通道未注册,降级到Flutter标准Clipboard + } on PlatformException { + // 权限或其他错误,降级到Flutter标准Clipboard + } + } + + // 桌面端(macOS/Windows):通过原生通道写入HTML富文本 + if (_supportsRichTextNative) { + try { + await _desktopChannel.invokeMethod('setRichText', { + 'text': plainText ?? '', + 'html': html ?? '', + }); + return; + } on MissingPluginException { + // 原生端未注册通道(主线程后续处理),降级为纯文本 + Log.w('ClipboardBridge: 桌面端富文本通道未注册,降级为纯文本写入'); + } on PlatformException catch (e) { + Log.w('ClipboardBridge: 桌面端富文本写入失败: $e,降级为纯文本写入'); + } + } + + // 移动端(iOS/Android)/Web/降级路径:仅写入纯文本 + if (plainText != null && plainText.isNotEmpty) { + await Clipboard.setData(ClipboardData(text: plainText)); + } + } + + /// 读取剪贴板 HTML 内容(仅桌面端有效) + /// + /// - 桌面端(macOS/Windows):通过原生 MethodChannel('apps.xy.xianyan/clipboard') + /// 读取 HTML 内容 + /// - 鸿蒙端/移动端(iOS/Android)/Web:不支持HTML读取,返回 null + /// + /// 未同意隐私协议时返回 null + static Future getHtml() async { + if (!_agreementAccepted) { + Log.w('ClipboardBridge: 隐私协议未同意,禁止读取剪贴板'); + return null; + } + + // 鸿蒙端:不支持HTML读取 + if (_isOhos) { + return null; + } + + // 桌面端:通过原生通道读取HTML + if (_supportsRichTextNative) { + try { + final result = await _desktopChannel.invokeMethod('getHtml'); + return result; + } on MissingPluginException { + // 原生端未注册通道,无法读取HTML + Log.w('ClipboardBridge: 桌面端富文本通道未注册,无法读取HTML'); + } on PlatformException catch (e) { + Log.w('ClipboardBridge: 桌面端HTML读取失败: $e'); + } + } + + // 移动端/Web:不支持HTML读取 + return null; + } + + /// 检查剪贴板是否有 HTML 内容 + /// + /// - 桌面端(macOS/Windows):通过原生通道检查HTML是否存在 + /// - 鸿蒙端/移动端/Web:不支持HTML,返回 false + /// + /// 未同意隐私协议时返回 false + static Future hasHtml() async { + if (!_agreementAccepted) { + Log.w('ClipboardBridge: 隐私协议未同意,禁止检查剪贴板'); + return false; + } + + // 鸿蒙端:不支持HTML + if (_isOhos) { + return false; + } + + // 桌面端:通过原生通道检查 + if (_supportsRichTextNative) { + try { + final html = await _desktopChannel.invokeMethod('getHtml'); + return html != null && html.isNotEmpty; + } on MissingPluginException { + // 原生端未注册通道 + } on PlatformException { + // 读取失败 + } + } + + return false; + } + + // ============================================================ + // 内部工具方法 + // ============================================================ + + /// 将 HTML 转换为纯文本(简单去除标签 + 常见实体反转义) + /// + /// 用于移动端/Web 降级路径,将 html 参数转为可读纯文本。 + /// 注意:此为简单实现,不处理复杂HTML结构(如表格、列表缩进)。 + static String? _htmlToPlainText(String? html) { + if (html == null || html.isEmpty) return null; + + // 块级标签转换为换行 + var text = html + .replaceAll(RegExp(r'(?i)'), '\n') + .replaceAll(RegExp(r'(?i)'), '\n') + .replaceAll(RegExp(r'(?i)'), '\n'); + + // 去除所有HTML标签 + text = text.replaceAll(RegExp(r'<[^>]*>'), ''); + + // 反转义常见HTML实体 + text = text + .replaceAll(' ', ' ') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'"); + + // 压缩多余的空白和换行(保留单个换行) + text = text.replaceAll(RegExp(r'[ \t]+'), ' '); + text = text.replaceAll(RegExp(r'\n{3,}'), '\n\n'); + text = text.trim(); + + return text.isEmpty ? null : text; + } } diff --git a/lib/features/article/presentation/article_detail_page.dart b/lib/features/article/presentation/article_detail_page.dart index 468ef078..17805d87 100644 --- a/lib/features/article/presentation/article_detail_page.dart +++ b/lib/features/article/presentation/article_detail_page.dart @@ -1,15 +1,16 @@ // ============================================================ // 闲言APP — 文章详情页面 // 创建时间: 2026-04-29 -// 更新时间: 2026-06-05 +// 更新时间: 2026-06-19 // 作用: 文章内容展示+评论+互动 -// 上次更新: 修复文章内容不显示问题;加载详情前清除旧数据;完善错误状态展示 +// 上次更新: 错误页返回按钮改用 context.appPop(),修复工作台模式下 Navigator.pop 弹空根栈导致白屏 // ============================================================ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/router/app_nav_extension.dart'; import '../../../core/theme/app_radius.dart'; import '../../../core/theme/app_spacing.dart'; import '../../../core/theme/app_typography.dart'; @@ -225,7 +226,7 @@ class _ArticleDetailPageState extends ConsumerState { ), const SizedBox(height: AppSpacing.sm), CupertinoButton( - onPressed: () => Navigator.pop(context), + onPressed: () => context.appPop(), child: Text('返回', style: TextStyle(color: ext.accent)), ), ], diff --git a/lib/features/article/presentation/article_edit_page.dart b/lib/features/article/presentation/article_edit_page.dart index 38f8d109..9fa77dd8 100644 --- a/lib/features/article/presentation/article_edit_page.dart +++ b/lib/features/article/presentation/article_edit_page.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 文章编辑/投稿页面 (增强版) // 创建时间: 2026-04-29 -// 更新时间: 2026-04-30 +// 更新时间: 2026-06-19 // 作用: 文章标题+内容+标签+分类编辑+字数统计+预览+交错动画 -// 上次更新: 统一错误提示为AppToast,移除_showAlert +// 上次更新: 发布成功后页面pop改用 context.appPop(),修复工作台模式下 Navigator.pop 弹空根栈导致白屏 // ============================================================ import 'package:flutter/cupertino.dart'; @@ -11,6 +11,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/router/app_nav_extension.dart'; import '../../../core/theme/app_radius.dart'; import '../../../core/theme/app_spacing.dart'; import '../../../core/theme/app_typography.dart'; @@ -625,7 +626,7 @@ class _ArticleEditPageState extends ConsumerState { isDefaultAction: true, onPressed: () { Navigator.pop(ctx); - Navigator.pop(context); + context.appPop(); }, child: const Text('好的'), ), diff --git a/lib/features/auth/presentation/qrcode_login_page.dart b/lib/features/auth/presentation/qrcode_login_page.dart index 9b6acf3a..4ac24d79 100644 --- a/lib/features/auth/presentation/qrcode_login_page.dart +++ b/lib/features/auth/presentation/qrcode_login_page.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 二维码登录页面 /// 创建时间: 2026-05-10 -/// 更新时间: 2026-06-02 +/// 更新时间: 2026-06-19 /// 作用: 扫码登录(扫描Web端二维码确认登录)+ 生成二维码(Web端扫码登录) -/// 上次更新: 修复dispose中使用ref导致StateError,改为缓存notifier引用 +/// 上次更新: 登录成功后页面pop改用 context.appPop(),修复工作台模式下 Navigator.pop 弹空根栈导致白屏 /// ============================================================ import 'dart:async'; @@ -240,7 +240,7 @@ class _QrcodeLoginPageState extends ConsumerState { Future.delayed(const Duration(seconds: 2), () { if (mounted) { - Navigator.of(context).pop(); + context.appPop(); context.appGo(AppRoutes.home); } }).catchError((_) {}); @@ -276,7 +276,7 @@ class _QrcodeLoginPageState extends ConsumerState { Future.delayed(const Duration(seconds: 2), () { if (mounted) { Navigator.of(context).pop(); - Navigator.of(context).pop(); + context.appPop(); } }).catchError((_) {}); } diff --git a/lib/features/correction/correction_provider.dart b/lib/features/correction/correction_provider.dart index ed0085e7..0ec1518b 100644 --- a/lib/features/correction/correction_provider.dart +++ b/lib/features/correction/correction_provider.dart @@ -1,13 +1,14 @@ /// ============================================================ /// 闲言APP — 纠错状态管理 /// 创建时间: 2026-04-28 -/// 更新时间: 2026-06-17 +/// 更新时间: 2026-06-19 /// 作用: 纠错提交功能状态管理 + 纠错历史本地缓存(drift) -/// 上次更新: 接入 drift 本地缓存,支持离线查看纠错历史 +/// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0 /// ============================================================ import 'package:drift/drift.dart' show Value; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../../core/network/api_client.dart'; import '../../../core/storage/database/app_database.dart'; @@ -97,7 +98,7 @@ class CorrectionItem { sourceId: sourceId, switchVal: switchVal, isLocal: isLocal, - createtime: map['createtime'] as int? ?? 0, + createtime: SafeJson.parseInt(map['createtime']), content: map['content'] as String? ?? '', username: map['username'] as String? ?? '', email: map['mail'] as String? ?? '', @@ -263,7 +264,7 @@ class CorrectionNotifier extends Notifier { data: submitData, ); final respData = response.data as Map; - final code = respData['code'] as int? ?? 0; + final code = SafeJson.parseInt(respData['code']); if (code == 1) { // 提交成功,写入本地缓存 final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000; @@ -332,13 +333,13 @@ class CorrectionNotifier extends Notifier { queryParameters: {'page': page, 'limit': limit}, ); final data = response.data as Map; - final code = data['code'] as int? ?? 0; + final code = SafeJson.parseInt(data['code']); if (code == 1) { final result = data['data'] as Map? ?? {}; final list = (result['list'] as List? ?? []) .map((e) => CorrectionItem.fromServerMap(e as Map)) .toList(); - final total = result['total'] as int? ?? 0; + final total = SafeJson.parseInt(result['total']); // 3. 全量替换本地缓存 await _db.replaceCorrectionRecords( diff --git a/lib/features/ctc/presentation/pages/ctc_note_list_page.dart b/lib/features/ctc/presentation/pages/ctc_note_list_page.dart index fc9899ae..82e8863c 100644 --- a/lib/features/ctc/presentation/pages/ctc_note_list_page.dart +++ b/lib/features/ctc/presentation/pages/ctc_note_list_page.dart @@ -1,8 +1,8 @@ /// 创建时间: 2026-06-11 -/// 更新时间: 2026-06-11 +/// 更新时间: 2026-06-19 /// 名称: CTC笔记仓库列表页 /// 作用: 笔记仓库主页面,展示所有笔记列表,支持网格/列表/时间线三种布局 -/// 上次更新: 接入KeyboardManager,点击非输入区收起键盘,搜索栏适配 +/// 上次更新: 导航栏返回按钮改用 context.appPop(),修复工作台模式下 Navigator.pop 弹空根栈导致白屏 import 'package:custom_refresh_indicator/custom_refresh_indicator.dart'; import 'package:flutter/cupertino.dart'; @@ -12,6 +12,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shimmer/shimmer.dart'; import 'package:timeago/timeago.dart' as timeago; +import 'package:xianyan/core/router/app_nav_extension.dart'; import 'package:xianyan/core/theme/app_theme.dart'; import 'package:xianyan/features/note/presentation/note_shared_widgets.dart'; import 'package:xianyan/features/ctc/ctc.dart'; @@ -98,7 +99,7 @@ class _CtcNoteListPageState extends ConsumerState { ) : CupertinoButton( padding: EdgeInsets.zero, - onPressed: () => Navigator.of(context).pop(), + onPressed: () => context.appPop(), child: Icon(CupertinoIcons.back, color: ext.iconTintBlue), ), trailing: Row( diff --git a/lib/features/ctc/providers/ctc_note_provider.dart b/lib/features/ctc/providers/ctc_note_provider.dart index 5e440db5..65549165 100644 --- a/lib/features/ctc/providers/ctc_note_provider.dart +++ b/lib/features/ctc/providers/ctc_note_provider.dart @@ -1,10 +1,11 @@ /// 创建时间: 2026-06-11 -/// 更新时间: 2026-06-11 +/// 更新时间: 2026-06-19 /// 名称: CTC笔记列表Provider /// 作用: 管理笔记仓库的列表状态、CRUD操作、同步、离线队列 -/// 上次更新: 增加10个笔记上限校验、离线队列+重试机制、同步状态追踪 +/// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0 import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../models/ctc_note_model.dart'; import '../models/ctc_tag_model.dart'; @@ -224,8 +225,8 @@ class CtcNoteListNotifier extends Notifier { final note = CtcNoteModel( key: key, content: content, - size: data?['size'] as int? ?? content.length, - mtime: data?['mtime'] as int?, + size: SafeJson.parseInt(data?['size'], content.length), + mtime: data?['mtime'] == null ? null : SafeJson.parseInt(data?['mtime']), addedAt: DateTime.now().millisecondsSinceEpoch, ); await storage.saveNote(note); @@ -556,8 +557,8 @@ class CtcNoteListNotifier extends Notifier { final data = info['data'] as Map?; if (data != null) { final updated = note.copyWith( - mtime: data['mtime'] as int?, - size: data['size'] as int? ?? note.size, + mtime: data['mtime'] == null ? null : SafeJson.parseInt(data['mtime']), + size: SafeJson.parseInt(data['size'], note.size), hasRemoteChange: false, ); await storage.saveNote(updated); diff --git a/lib/features/ctc/services/ctc_api_client.dart b/lib/features/ctc/services/ctc_api_client.dart index e23f6b3e..3ab274d7 100644 --- a/lib/features/ctc/services/ctc_api_client.dart +++ b/lib/features/ctc/services/ctc_api_client.dart @@ -1,11 +1,12 @@ /// 创建时间: 2026-06-11 -/// 更新时间: 2026-06-11 +/// 更新时间: 2026-06-19 /// 名称: CTC API客户端 /// 作用: 封装ctc.s2ss.com的所有HTTP接口调用 -/// 上次更新: 初始创建 +/// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0 import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../models/ctc_note_model.dart'; @@ -117,8 +118,8 @@ class CtcApiClient { final info = data['data'] as Map?; if (info == null) return null; return local.copyWith( - size: info['size'] as int? ?? local.size, - mtime: info['mtime'] as int?, + size: SafeJson.parseInt(info['size'], local.size), + mtime: info['mtime'] == null ? null : SafeJson.parseInt(info['mtime']), ); } } diff --git a/lib/features/daily_card/daily_card_provider.dart b/lib/features/daily_card/daily_card_provider.dart index a22c83fe..b3e59153 100644 --- a/lib/features/daily_card/daily_card_provider.dart +++ b/lib/features/daily_card/daily_card_provider.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 日签卡片状态管理 /// 创建时间: 2026-05-01 -/// 更新时间: 2026-05-27 +/// 更新时间: 2026-06-19 /// 作用: 日签卡片数据加载 + 样式切换 + 内容类型切换 + 切换下一句 + 刷新同类型 -/// 上次更新: 新增refreshCurrentType方法,"换一句"改为刷新同类型内容 +/// 上次更新: 修复换一句无响应——添加isRefreshing状态+错误反馈+频道切换支持 /// ============================================================ /// ============================================================ @@ -24,6 +24,8 @@ class DailyCardPageState { this.currentStyle = CardStylePreset.frostedGlass, this.currentContentType, this.isLoading = true, + this.isRefreshing = false, + this.refreshError, this.error, }); @@ -31,6 +33,8 @@ class DailyCardPageState { final CardStylePreset currentStyle; final DailyContentType? currentContentType; final bool isLoading; + final bool isRefreshing; + final String? refreshError; final String? error; DailyCardContent get displayContent { @@ -53,6 +57,9 @@ class DailyCardPageState { CardStylePreset? currentStyle, DailyContentType? currentContentType, bool? isLoading, + bool? isRefreshing, + String? refreshError, + bool clearRefreshError = false, bool clearContentType = false, String? error, }) => DailyCardPageState( @@ -62,6 +69,8 @@ class DailyCardPageState { ? null : (currentContentType ?? this.currentContentType), isLoading: isLoading ?? this.isLoading, + isRefreshing: isRefreshing ?? this.isRefreshing, + refreshError: clearRefreshError ? null : (refreshError ?? this.refreshError), error: error, ); @@ -202,15 +211,39 @@ class DailyCardPageNotifier extends Notifier { } /// 刷新当前类型的同类型新内容(从服务端获取) + /// + /// 支持频道切换:根据当前内容类型从对应频道获取新句子。 + /// 设置 isRefreshing 状态供 UI 显示加载态,失败时记录 refreshError。 Future refreshCurrentType() async { final data = state.cardData; - if (data == null) return; + if (data == null) { + state = state.copyWith( + refreshError: '卡片数据未加载,无法刷新', + ); + return; + } final typeToRefresh = state.currentContentType ?? data.primaryContent.type; + // 设置刷新中状态 + state = state.copyWith( + isRefreshing: true, + clearRefreshError: true, + ); + try { final newContent = await _fetchByType(typeToRefresh); + // 检查服务端是否返回了有效内容(非兜底内容) + if (newContent.text.isEmpty) { + state = state.copyWith( + isRefreshing: false, + refreshError: '获取新内容为空,请稍后重试', + ); + Log.w('日签刷新: 服务端返回空内容'); + return; + } + final Map newMap; switch (typeToRefresh) { case DailyContentType.poetry: @@ -248,10 +281,18 @@ class DailyCardPageNotifier extends Notifier { story: typeToRefresh == DailyContentType.story ? newMap : data.story, ); - state = state.copyWith(cardData: newCardData); - Log.i('日签刷新同类型内容: ${typeToRefresh.label}'); + state = state.copyWith( + cardData: newCardData, + isRefreshing: false, + clearRefreshError: true, + ); + Log.i('日签刷新同类型内容成功: ${typeToRefresh.label}'); } catch (e) { Log.e('刷新同类型内容失败', e); + state = state.copyWith( + isRefreshing: false, + refreshError: '刷新失败,请稍后重试', + ); } } @@ -268,6 +309,13 @@ class DailyCardPageNotifier extends Notifier { return DailyCardService.fetchStory(); } } + + /// 清除刷新错误状态(UI 显示 toast 后调用) + void clearRefreshError() { + if (state.refreshError != null) { + state = state.copyWith(clearRefreshError: true); + } + } } // ============================================================ diff --git a/lib/features/daily_card/presentation/daily_card_page.dart b/lib/features/daily_card/presentation/daily_card_page.dart index 483e64f6..967bb21b 100644 --- a/lib/features/daily_card/presentation/daily_card_page.dart +++ b/lib/features/daily_card/presentation/daily_card_page.dart @@ -349,9 +349,24 @@ class _DailyCardPageState extends ConsumerState { } Widget _buildRefreshButton(AppThemeExtension ext, AppRadiusData radius) { + final cardState = ref.watch(dailyCardPageProvider); + final isRefreshing = cardState.isRefreshing; + final refreshError = cardState.refreshError; + + // 错误时显示 toast 提示 + if (refreshError != null && !isRefreshing) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) AppToast.showError(refreshError); + // 清除错误状态,避免重复显示 + ref.read(dailyCardPageProvider.notifier).clearRefreshError(); + }); + } + return GestureDetector( - onTap: () => - ref.read(dailyCardPageProvider.notifier).refreshCurrentType(), + onTap: isRefreshing + ? null + : () => + ref.read(dailyCardPageProvider.notifier).refreshCurrentType(), child: AnimatedContainer( duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, @@ -360,31 +375,47 @@ class _DailyCardPageState extends ConsumerState { vertical: AppSpacing.sm, ), decoration: BoxDecoration( - gradient: LinearGradient(colors: [ext.accent, ext.accentLight]), + gradient: LinearGradient( + colors: isRefreshing + ? [ext.bgSecondary, ext.bgSecondary] + : [ext.accent, ext.accentLight], + ), borderRadius: BorderRadius.circular(radius.md), - boxShadow: [ - BoxShadow( - color: ext.accent.withValues(alpha: 0.3), - blurRadius: 10, - offset: const Offset(0, 3), - ), - ], + boxShadow: isRefreshing + ? null + : [ + BoxShadow( + color: ext.accent.withValues(alpha: 0.3), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - CupertinoIcons.arrow_2_circlepath, - size: 15, - color: ext.textOnAccent, - ), + if (isRefreshing) + SizedBox( + width: 15, + height: 15, + child: CupertinoActivityIndicator( + color: ext.textSecondary, + radius: 7, + ), + ) + else + Icon( + CupertinoIcons.arrow_2_circlepath, + size: 15, + color: ext.textOnAccent, + ), const SizedBox(width: 4), Text( - '换一句', + isRefreshing ? '切换中…' : '换一句', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, - color: ext.textOnAccent, + color: isRefreshing ? ext.textSecondary : ext.textOnAccent, ), ), ], diff --git a/lib/features/desktop/desktop_tray_controller.dart b/lib/features/desktop/desktop_tray_controller.dart new file mode 100644 index 00000000..1dd8ea45 --- /dev/null +++ b/lib/features/desktop/desktop_tray_controller.dart @@ -0,0 +1,360 @@ +/// ============================================================ +/// 闲言APP — 桌面端托盘控制器 +/// 创建时间: 2026-06-18 +/// 更新时间: 2026-06-18 +/// 作用: 整合托盘服务 + 菜单构建器 + 未读数 Provider,管理托盘生命周期 +/// 上次更新: 初始创建,实现 DesktopTrayController +/// ============================================================ + +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../core/providers/split_view_provider.dart'; +import '../../core/router/app_router.dart'; +import '../../core/services/desktop/desktop_tray_menu_builder.dart'; +import '../../core/services/desktop/desktop_tray_service.dart'; +import '../../core/utils/logger.dart'; +import '../../core/utils/platform/platform_utils.dart' as pu; +import '../../features/home/presentation/providers/readlater/readlater_provider.dart'; +import '../../features/home/presentation/providers/readlater/tray_unread_count_provider.dart'; +import '../../features/settings/providers/general_settings_provider.dart'; +import '../../features/settings/providers/theme_settings_provider.dart'; + +/// 桌面端托盘控制器 +/// +/// 职责: +/// 1. 初始化/销毁托盘服务 +/// 2. 监听未读数变化,更新托盘角标 +/// 3. 监听主题变化,更新托盘图标 +/// 4. 监听稍后阅读列表变化,更新托盘菜单 +/// 5. 处理托盘事件(单击显示/隐藏、双击聚焦、右键菜单) +/// 6. 提供菜单项回调实现(导航、窗口控制、主题切换) +/// +/// 使用方式: +/// ```dart +/// // 在 app.dart 的 initState 中 +/// _trayController = DesktopTrayController(ref); +/// await _trayController.initialize(); +/// +/// // 在 dispose 中 +/// await _trayController.dispose(); +/// ``` +class DesktopTrayController { + DesktopTrayController(this._ref); + + final WidgetRef _ref; + StreamSubscription? _eventSub; + ProviderSubscription? _unreadSub; + bool _initialized = false; + bool _isWindowVisible = true; + /// 防止 popUpContextMenu 触发的 performClick 导致重入 + bool _isPopUpMenuInProgress = false; + + /// 初始化托盘控制器 + Future initialize() async { + if (!pu.isDesktop) return; + if (_initialized) return; + + try { + final trayService = DesktopTrayService.instance; + if (!trayService.isSupported) { + Log.w('DesktopTrayController: 当前平台不支持托盘'); + return; + } + + // 1. 初始化托盘服务 + await trayService.init(); + + // 2. 设置初始图标 + final isDark = _ref.read(themeSettingsProvider).isDark; + await trayService.setIcon(isDark: isDark); + + // 3. 设置初始 Tooltip + final unreadCount = _ref.read(trayUnreadCountProvider); + final languageId = _ref.read(generalSettingsProvider).languageId; + final labels = TrayMenuLabels.forLanguage(languageId); + await trayService.setToolTip(labels.formatTooltip(unreadCount)); + + // 标记已初始化(必须在调用 _updateMenu / _onUnreadCountChanged 之前设置, + // 否则这两个方法的 `if (!_initialized) return;` 守卫会导致菜单和角标不更新) + _initialized = true; + + // 4. 设置初始菜单 + await _updateMenu(); + + // 5. 设置初始未读角标 + await trayService.setUnreadBadge(unreadCount); + + // 6. 监听未读数变化 + _unreadSub = _ref.listenManual( + trayUnreadCountProvider, + (previous, next) { + _onUnreadCountChanged(next); + }, + ); + + // 7. 监听托盘事件 + _eventSub = trayService.events.listen(_onTrayEvent); + + Log.i('DesktopTrayController 初始化完成'); + } catch (e, st) { + Log.e('DesktopTrayController 初始化失败', e, st); + } + } + + /// 销毁托盘控制器 + Future dispose() async { + await _eventSub?.cancel(); + _eventSub = null; + _unreadSub?.close(); + _unreadSub = null; + + if (_initialized) { + try { + await DesktopTrayService.instance.destroy(); + } catch (e) { + Log.e('DesktopTrayController.dispose 销毁托盘失败: $e'); + } + _initialized = false; + } + } + + // ============================================================ + // 主题切换响应(由 app.dart 调用) + // ============================================================ + + /// 主题切换时更新托盘图标 + Future onThemeChanged(bool isDark) async { + if (!_initialized) return; + try { + await DesktopTrayService.instance.setIcon(isDark: isDark); + await _updateMenu(); // 菜单中的"深色模式"勾选状态需要更新 + } catch (e) { + Log.e('DesktopTrayController.onThemeChanged 失败: $e'); + } + } + + // ============================================================ + // 内部方法 + // ============================================================ + + /// 更新托盘菜单 + Future _updateMenu() async { + if (!_initialized) return; + try { + final readLaterState = _ref.read(readLaterProvider); + final isDark = _ref.read(themeSettingsProvider).isDark; + final isWorkbench = _ref.read(splitViewProvider).workbenchEnabled; + final languageId = _ref.read(generalSettingsProvider).languageId; + final labels = TrayMenuLabels.forLanguage(languageId); + + final menu = DesktopTrayMenuBuilder.build( + readLaterEntries: readLaterState.entries, + isDark: isDark, + isWorkbenchMode: isWorkbench, + labels: labels, + callbacks: TrayMenuCallbacks( + onNewNote: _onNewNote, + onNewInspiration: _onNewInspiration, + onOpenReadLater: _onOpenReadLater, + onToggleWorkbench: _onToggleWorkbench, + onToggleDarkMode: _onToggleDarkMode, + onShowMainWindow: _onShowMainWindow, + onOpenSettings: _onOpenSettings, + onExit: _onExit, + onOpenReadLaterEntry: _onOpenReadLaterEntry, + ), + ); + + await DesktopTrayService.instance.setMenu(menu); + } catch (e, st) { + Log.e('DesktopTrayController._updateMenu 失败: $e', e, st); + } + } + + /// 未读数变化处理 + Future _onUnreadCountChanged(int count) async { + if (!_initialized) return; + try { + await DesktopTrayService.instance.setUnreadBadge(count); + + final languageId = _ref.read(generalSettingsProvider).languageId; + final labels = TrayMenuLabels.forLanguage(languageId); + await DesktopTrayService.instance.setToolTip( + labels.formatTooltip(count), + ); + + // 未读数变化时也更新菜单(最近阅读列表可能变化) + await _updateMenu(); + } catch (e) { + Log.e('DesktopTrayController._onUnreadCountChanged 失败: $e'); + } + } + + /// 托盘事件处理 + /// + /// macOS 行为说明: + /// - tray_manager 的 macOS 实现不会自动弹出上下文菜单 + /// (setContextMenu 只存储菜单,mouseDown 只触发回调) + /// - 需要在 click 事件中手动调用 popUpContextMenu() 弹出菜单 + /// - popUpContextMenu 内部调用 performClick 会触发另一次 mouseDown, + /// 需要防重入保护,避免无限循环和误判为双击 + void _onTrayEvent(TrayEvent event) { + // 如果正在弹出菜单,忽略所有事件(防止 performClick 触发的重入) + if (_isPopUpMenuInProgress) { + return; + } + switch (event.kind) { + case TrayEventKind.click: + // macOS/Windows: 手动弹出上下文菜单 + _popUpContextMenu(); + case TrayEventKind.doubleClick: + // 双击:聚焦主窗口(但 popUpContextMenu 的 performClick 可能误触发双击, + // 已被 _isPopUpMenuInProgress 保护) + _focusMainWindow(); + case TrayEventKind.rightClick: + // 右键:同样手动弹出上下文菜单 + _popUpContextMenu(); + break; + } + } + + /// 弹出托盘上下文菜单 + /// + /// tray_manager 的 macOS 实现不会自动弹出菜单, + /// 需要调用 DesktopTrayService.popUpContextMenu() 手动弹出。 + /// popUpContextMenu 内部调用 performClick 会触发另一次 mouseDown, + /// 使用 _isPopUpMenuInProgress 标志防止重入。 + Future _popUpContextMenu() async { + if (_isPopUpMenuInProgress) return; + _isPopUpMenuInProgress = true; + try { + await DesktopTrayService.instance.popUpContextMenu(); + } catch (e, st) { + Log.e('DesktopTrayController._popUpContextMenu 失败: $e', e, st); + } finally { + // 延迟重置标志,避免 performClick 触发的 mouseDown 事件被误处理 + Future.delayed(const Duration(milliseconds: 300), () { + _isPopUpMenuInProgress = false; + }); + } + } + + /// 切换窗口可见性(保留供未来"隐藏到托盘"菜单项使用) + /// + /// 使用 minimize + setSkipTaskbar 组合替代 hide(), + /// 因为 windowManager.hide() 在 macOS 上与 macos_window_utils 的 + /// NSVisualEffectView + titlebarAppearsTransparent 组合会导致原生崩溃。 + // ignore: unused_element + Future _toggleWindowVisibility() async { + try { + if (_isWindowVisible) { + // 最小化到托盘:先隐藏 dock 图标,再最小化窗口 + await windowManager.setSkipTaskbar(true); + await windowManager.minimize(); + _isWindowVisible = false; + } else { + // 从托盘恢复:先取消隐藏 dock 图标,再显示并聚焦窗口 + await windowManager.setSkipTaskbar(false); + await windowManager.show(); + await windowManager.focus(); + _isWindowVisible = true; + } + } catch (e, st) { + Log.e('DesktopTrayController._toggleWindowVisibility 失败: $e', e, st); + } + } + + /// 聚焦主窗口 + Future _focusMainWindow() async { + try { + await windowManager.setSkipTaskbar(false); + await windowManager.show(); + await windowManager.focus(); + _isWindowVisible = true; + } catch (e, st) { + Log.e('DesktopTrayController._focusMainWindow 失败: $e', e, st); + } + } + + // ============================================================ + // 菜单项回调 + // ============================================================ + + void _onNewNote() { + _navigateTo(AppRoutes.noteEdit); + } + + void _onNewInspiration() { + _navigateTo(AppRoutes.inspiration); + } + + void _onOpenReadLater() { + _navigateTo(AppRoutes.readLater); + } + + void _onToggleWorkbench() { + final current = _ref.read(splitViewProvider).workbenchEnabled; + _ref.read(splitViewProvider.notifier).setWorkbenchEnabled(!current); + _updateMenu(); + } + + void _onToggleDarkMode() { + final settings = _ref.read(themeSettingsProvider); + final newMode = settings.isDark + ? AppThemeMode.light + : AppThemeMode.dark; + _ref.read(themeSettingsProvider.notifier).setThemeMode(newMode); + // 主题变化会触发 onThemeChanged 回调,无需在此更新菜单 + } + + void _onShowMainWindow() { + _focusMainWindow(); + } + + void _onOpenSettings() { + _navigateTo(AppRoutes.generalSettings); + } + + void _onExit() async { + try { + await dispose(); + await windowManager.destroy(); + } catch (e) { + Log.e('DesktopTrayController._onExit 失败: $e'); + // 强制退出 + SystemNavigator.pop(); + } + } + + void _onOpenReadLaterEntry(String entryId) { + // 导航到稍后阅读页面,并定位到指定条目 + _navigateTo(AppRoutes.readLater); + // 后续可扩展为直接打开条目详情 + } + + /// 导航到指定路由 + void _navigateTo(String route) { + try { + // 先显示窗口 + _focusMainWindow(); + + final context = _ref.context; + if (!context.mounted) { + Log.w('DesktopTrayController._navigateTo: context 不可用'); + return; + } + + if (pu.isOhos) { + // 鸿蒙端导航(虽然托盘只在桌面端,但保持一致性) + } else { + appRouter.push(route); + } + } catch (e) { + Log.e('DesktopTrayController._navigateTo 失败: $e'); + } + } +} diff --git a/lib/features/desktop/desktop_window_title_bar.dart b/lib/features/desktop/desktop_window_title_bar.dart new file mode 100644 index 00000000..d42e49ab --- /dev/null +++ b/lib/features/desktop/desktop_window_title_bar.dart @@ -0,0 +1,502 @@ +/// ============================================================ +/// 闲言APP — 桌面端自定义窗口标题栏 +/// 创建时间: 2026-06-18 +/// 更新时间: 2026-06-18 +/// 作用: 软件样式标题栏,替代系统默认标题栏,支持动态主题+动态样式 +/// 上次更新: 初始创建,实现 macOS 风格(红黄绿) + Windows 风格(─ ▢ ✕) +/// ============================================================ + +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../core/theme/app_theme.dart'; +import '../../core/utils/logger.dart'; +import '../../core/utils/platform/platform_utils.dart' as pu; +import '../../features/settings/providers/theme_settings_provider.dart'; + +/// 标题栏样式配置(动态样式) +class DesktopTitleBarStyle { + /// 标题栏高度 + final double height; + + /// 背景透明度(0.0-1.0,用于毛玻璃效果) + final double backgroundOpacity; + + /// 模糊强度(毛玻璃效果) + final double blurRadius; + + /// 按钮大小 + final double buttonSize; + + /// 按钮间距 + final double buttonSpacing; + + /// 标题字体大小 + final double titleFontSize; + + /// 标题字重 + final FontWeight titleFontWeight; + + /// 圆角半径(Windows 风格按钮) + final double buttonRadius; + + const DesktopTitleBarStyle({ + this.height = 32.0, + this.backgroundOpacity = 0.6, + this.blurRadius = 20.0, + this.buttonSize = 14.0, + this.buttonSpacing = 8.0, + this.titleFontSize = 13.0, + this.titleFontWeight = FontWeight.w500, + this.buttonRadius = 6.0, + }); + + /// macOS 风格默认样式 + const DesktopTitleBarStyle.macOS() + : height = 32.0, + backgroundOpacity = 0.5, + blurRadius = 30.0, + buttonSize = 12.0, + buttonSpacing = 8.0, + titleFontSize = 13.0, + titleFontWeight = FontWeight.w500, + buttonRadius = 6.0; + + /// Windows 风格默认样式 + const DesktopTitleBarStyle.windows() + : height = 36.0, + backgroundOpacity = 0.7, + blurRadius = 20.0, + buttonSize = 14.0, + buttonSpacing = 0.0, + titleFontSize = 12.0, + titleFontWeight = FontWeight.w400, + buttonRadius = 4.0; +} + +/// 桌面端自定义窗口标题栏 +/// +/// 软件样式标题栏,替代系统默认标题栏: +/// - macOS 风格:左侧红黄绿三圆点按钮 + 中间标题 +/// - Windows 风格:左侧标题 + 右侧最小化/最大化/关闭方按钮 +/// +/// 支持动态主题(深色/浅色/AMOLED)和动态样式(高度/透明度/模糊等)。 +/// 使用毛玻璃效果增强层次感。 +class DesktopWindowTitleBar extends ConsumerStatefulWidget { + const DesktopWindowTitleBar({ + super.key, + this.title, + this.style, + this.leading, + this.actions, + this.showTitle = true, + }); + + /// 标题文本(null 时显示应用名称"闲言") + final String? title; + + /// 自定义样式(null 时根据平台自动选择) + final DesktopTitleBarStyle? style; + + /// 标题栏左侧自定义 Widget(在按钮之后) + final Widget? leading; + + /// 标题栏右侧自定义 Widget(在按钮之前) + final List? actions; + + /// 是否显示标题 + final bool showTitle; + + @override + ConsumerState createState() => + _DesktopWindowTitleBarState(); +} + +class _DesktopWindowTitleBarState extends ConsumerState + with WindowListener { + bool _isMaximized = false; + bool _isHoveringClose = false; + bool _isHoveringMinimize = false; + bool _isHoveringMaximize = false; + + @override + void initState() { + super.initState(); + windowManager.addListener(this); + _refreshMaximizedState(); + } + + @override + void dispose() { + windowManager.removeListener(this); + super.dispose(); + } + + /// 刷新窗口最大化状态 + Future _refreshMaximizedState() async { + if (!mounted) return; + try { + final maximized = await windowManager.isMaximized(); + if (mounted && _isMaximized != maximized) { + setState(() => _isMaximized = maximized); + } + } catch (e) { + Log.w('DesktopWindowTitleBar._refreshMaximizedState 失败: $e'); + } + } + + @override + void onWindowMaximize() { + if (mounted) setState(() => _isMaximized = true); + } + + @override + void onWindowUnmaximize() { + if (mounted) setState(() => _isMaximized = false); + } + + @override + Widget build(BuildContext context) { + final theme = ref.watch(themeSettingsProvider); + final ext = AppTheme.ext(context); + final isDark = theme.isDark; + final isAmoled = theme.isAmoled; + + // 根据平台选择默认样式 + final style = widget.style ?? + (pu.isMacOS + ? const DesktopTitleBarStyle.macOS() + : const DesktopTitleBarStyle.windows()); + + // 背景颜色:根据主题动态变化 + final backgroundColor = _resolveBackgroundColor(isDark, isAmoled, ext); + + // 前景颜色 + final foregroundColor = isDark + ? const Color(0xFFE5E5E7) + : const Color(0xFF1D1D1F); + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: (_) => windowManager.startDragging(), + onDoubleTap: _toggleMaximize, + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: style.blurRadius, + sigmaY: style.blurRadius, + ), + child: Container( + height: style.height, + color: backgroundColor.withValues(alpha: style.backgroundOpacity), + child: pu.isMacOS + ? _buildMacOSLayout(style, foregroundColor) + : _buildWindowsLayout(style, foregroundColor), + ), + ), + ), + ); + } + + /// 解析背景颜色(动态主题) + Color _resolveBackgroundColor( + bool isDark, + bool isAmoled, + AppThemeExtension ext, + ) { + if (isAmoled) { + return const Color(0xFF000000); + } + if (isDark) { + return const Color(0xFF1C1C1E); + } + return const Color(0xFFF5F5F7); + } + + // ============================================================ + // macOS 风格布局:左侧红黄绿按钮 + 中间标题 + // ============================================================ + + Widget _buildMacOSLayout(DesktopTitleBarStyle style, Color foregroundColor) { + return Row( + children: [ + // 左侧:红黄绿按钮 + SizedBox( + width: 72, + child: Row( + children: [ + const SizedBox(width: 12), + _MacOSTrafficButton( + color: const Color(0xFFFF5F57), + icon: CupertinoIcons.xmark, + isHovering: _isHoveringClose, + onHover: (v) => setState(() => _isHoveringClose = v), + onTap: () => windowManager.close(), + size: style.buttonSize, + ), + SizedBox(width: style.buttonSpacing), + _MacOSTrafficButton( + color: const Color(0xFFFEBC2E), + icon: CupertinoIcons.minus, + isHovering: _isHoveringMinimize, + onHover: (v) => setState(() => _isHoveringMinimize = v), + onTap: () => windowManager.minimize(), + size: style.buttonSize, + ), + SizedBox(width: style.buttonSpacing), + _MacOSTrafficButton( + color: const Color(0xFF28C840), + icon: _isMaximized + ? Icons.fullscreen_exit + : Icons.fullscreen, + isHovering: _isHoveringMaximize, + onHover: (v) => setState(() => _isHoveringMaximize = v), + onTap: _toggleMaximize, + size: style.buttonSize, + ), + ], + ), + ), + // 中间:标题 + 自定义 leading + Expanded( + child: Row( + children: [ + if (widget.leading != null) widget.leading!, + if (widget.showTitle) + Expanded( + child: Center( + child: Text( + widget.title ?? '闲言', + style: TextStyle( + fontSize: style.titleFontSize, + fontWeight: style.titleFontWeight, + color: foregroundColor, + decoration: TextDecoration.none, + ), + ), + ), + ), + if (widget.actions != null) ...widget.actions!, + ], + ), + ), + // 右侧占位(保持对称) + const SizedBox(width: 72), + ], + ); + } + + // ============================================================ + // Windows 风格布局:左侧标题 + 右侧最小化/最大化/关闭 + // ============================================================ + + Widget _buildWindowsLayout( + DesktopTitleBarStyle style, + Color foregroundColor, + ) { + return Row( + children: [ + // 左侧:标题 + 自定义 leading + Expanded( + child: Row( + children: [ + if (widget.leading != null) widget.leading!, + if (widget.showTitle) + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 12), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + widget.title ?? '闲言', + style: TextStyle( + fontSize: style.titleFontSize, + fontWeight: style.titleFontWeight, + color: foregroundColor, + decoration: TextDecoration.none, + ), + ), + ), + ), + ), + if (widget.actions != null) ...widget.actions!, + ], + ), + ), + // 右侧:最小化/最大化/关闭按钮 + _WindowsControlButton( + icon: CupertinoIcons.minus, + iconSize: 16, + isHovering: _isHoveringMinimize, + onHover: (v) => setState(() => _isHoveringMinimize = v), + onTap: () => windowManager.minimize(), + foregroundColor: foregroundColor, + width: 46, + height: style.height, + ), + _WindowsControlButton( + icon: _isMaximized + ? Icons.fullscreen_exit + : Icons.fullscreen, + iconSize: 14, + isHovering: _isHoveringMaximize, + onHover: (v) => setState(() => _isHoveringMaximize = v), + onTap: _toggleMaximize, + foregroundColor: foregroundColor, + width: 46, + height: style.height, + ), + _WindowsControlButton( + icon: CupertinoIcons.xmark, + iconSize: 16, + isHovering: _isHoveringClose, + onHover: (v) => setState(() => _isHoveringClose = v), + onTap: () => windowManager.close(), + foregroundColor: foregroundColor, + width: 46, + height: style.height, + isClose: true, + ), + ], + ); + } + + /// 切换最大化/还原 + Future _toggleMaximize() async { + try { + if (_isMaximized) { + await windowManager.unmaximize(); + } else { + await windowManager.maximize(); + } + } catch (e) { + Log.e('DesktopWindowTitleBar._toggleMaximize 失败: $e'); + } + } +} + +// ============================================================ +// macOS 风格红黄绿按钮 +// ============================================================ + +class _MacOSTrafficButton extends StatefulWidget { + const _MacOSTrafficButton({ + required this.color, + required this.icon, + required this.isHovering, + required this.onHover, + required this.onTap, + required this.size, + }); + + final Color color; + final IconData icon; + final bool isHovering; + final ValueChanged onHover; + final VoidCallback onTap; + final double size; + + @override + State<_MacOSTrafficButton> createState() => _MacOSTrafficButtonState(); +} + +class _MacOSTrafficButtonState extends State<_MacOSTrafficButton> { + @override + Widget build(BuildContext context) { + // macOS 风格:圆形按钮,hover 时显示图标 + final buttonDiameter = widget.size + 2; + + return MouseRegion( + onEnter: (_) => widget.onHover(true), + onExit: (_) => widget.onHover(false), + child: GestureDetector( + onTap: widget.onTap, + child: Container( + width: buttonDiameter, + height: buttonDiameter, + decoration: BoxDecoration( + color: widget.color, + shape: BoxShape.circle, + border: Border.all( + color: widget.color.withValues(alpha: 0.5), + width: 0.5, + ), + ), + child: widget.isHovering + ? Icon( + widget.icon, + size: widget.size * 0.6, + color: const Color(0xFF1D1D1F).withValues(alpha: 0.6), + ) + : null, + ), + ), + ); + } +} + +// ============================================================ +// Windows 风格方形按钮 +// ============================================================ + +class _WindowsControlButton extends StatefulWidget { + const _WindowsControlButton({ + required this.icon, + required this.iconSize, + required this.isHovering, + required this.onHover, + required this.onTap, + required this.foregroundColor, + required this.width, + required this.height, + this.isClose = false, + }); + + final IconData icon; + final double iconSize; + final bool isHovering; + final ValueChanged onHover; + final VoidCallback onTap; + final Color foregroundColor; + final double width; + final double height; + final bool isClose; + + @override + State<_WindowsControlButton> createState() => _WindowsControlButtonState(); +} + +class _WindowsControlButtonState extends State<_WindowsControlButton> { + @override + Widget build(BuildContext context) { + // Windows 风格:方形按钮,hover 时背景变化 + final hoverBg = widget.isClose + ? const Color(0xFFC42B1C) + : widget.foregroundColor.withValues(alpha: 0.1); + final hoverIcon = widget.isClose + ? CupertinoColors.white + : widget.foregroundColor; + + return MouseRegion( + onEnter: (_) => widget.onHover(true), + onExit: (_) => widget.onHover(false), + child: GestureDetector( + onTap: widget.onTap, + child: Container( + width: widget.width, + height: widget.height, + color: widget.isHovering ? hoverBg : const Color(0x00000000), + child: Icon( + widget.icon, + size: widget.iconSize, + color: widget.isHovering ? hoverIcon : widget.foregroundColor, + ), + ), + ), + ); + } +} diff --git a/lib/features/desktop/macos_menu_bar_wrapper.dart b/lib/features/desktop/macos_menu_bar_wrapper.dart new file mode 100644 index 00000000..01e4e1cc --- /dev/null +++ b/lib/features/desktop/macos_menu_bar_wrapper.dart @@ -0,0 +1,445 @@ +/// ============================================================ +/// 闲言APP — macOS 原生菜单栏包装器 +/// 创建时间: 2026-06-18 +/// 更新时间: 2026-06-18 +/// 作用: 使用 PlatformMenuBar 注册 macOS 顶部原生菜单(闲言/文件/编辑/视图/窗口/帮助) +/// 上次更新: 初始创建,实现 6 个顶级菜单 + 业务功能入口 +/// ============================================================ + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/providers/split_view_provider.dart'; +import '../../core/router/app_router.dart' show appRouter; +import '../../core/router/app_routes.dart'; +import '../../core/utils/logger.dart'; +import '../../core/utils/platform/platform_utils.dart' as pu; +import '../../features/settings/providers/theme_settings_provider.dart'; + +/// macOS 原生菜单栏包装器 +/// +/// 在 macOS 上使用 [PlatformMenuBar] 注册原生顶部菜单栏,包含: +/// 1. 闲言菜单:关于/偏好设置/新建笔记/新建灵感/隐藏/退出 +/// 2. 文件菜单:新建笔记/打开文件/关闭窗口 +/// 3. 编辑菜单:撤销/重做/剪切/复制/粘贴/全选(系统标准) +/// 4. 视图菜单:全屏/切换深色模式/工作台模式/切换侧边栏 +/// 5. 窗口菜单:最小化/缩放/关闭窗口(系统标准) +/// 6. 帮助菜单:闲言帮助/检查更新/反馈问题 +/// +/// 非 macOS 平台直接返回 child,不包裹 PlatformMenuBar。 +class MacosMenuBarWrapper extends ConsumerWidget { + const MacosMenuBarWrapper({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // 非 macOS 平台不注册原生菜单栏 + if (!pu.isMacOS) return child; + + // 监听主题和工作台模式状态(用于菜单勾选) + final isDark = ref.watch(themeSettingsProvider).isDark; + final isWorkbench = ref.watch(splitViewProvider).workbenchEnabled; + + return PlatformMenuBar( + menus: _buildMenus( + context: context, + ref: ref, + isDark: isDark, + isWorkbench: isWorkbench, + ), + child: child, + ); + } + + /// 构建所有菜单 + List _buildMenus({ + required BuildContext context, + required WidgetRef ref, + required bool isDark, + required bool isWorkbench, + }) { + return [ + _buildAppMenu(context, ref), + _buildFileMenu(context, ref), + _buildEditMenu(), + _buildViewMenu(context, ref, isDark, isWorkbench), + _buildWindowMenu(), + _buildHelpMenu(context, ref), + ]; + } + + // ============================================================ + // 闲言菜单(application) + // ============================================================ + + PlatformMenu _buildAppMenu(BuildContext context, WidgetRef ref) { + return PlatformMenu( + label: '闲言', + menus: [ + const PlatformMenuItemGroup( + members: [ + PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.about, + ), + ], + ), + PlatformMenuItemGroup( + members: [ + PlatformMenuItem( + label: '偏好设置…', + shortcut: const SingleActivator( + LogicalKeyboardKey.comma, + meta: true, + ), + onSelected: () => _navigateTo(AppRoutes.generalSettings), + ), + ], + ), + PlatformMenuItemGroup( + members: [ + PlatformMenuItem( + label: '新建笔记', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyN, + meta: true, + ), + onSelected: () => _navigateTo(AppRoutes.noteEdit), + ), + PlatformMenuItem( + label: '新建灵感', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyI, + meta: true, + ), + onSelected: () => _navigateTo(AppRoutes.inspiration), + ), + ], + ), + const PlatformMenuItemGroup( + members: [ + PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.hide, + ), + PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.hideOtherApplications, + ), + PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.showAllApplications, + ), + ], + ), + const PlatformMenuItemGroup( + members: [ + PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.quit, + ), + ], + ), + ], + ); + } + + // ============================================================ + // 文件菜单 + // ============================================================ + + PlatformMenu _buildFileMenu(BuildContext context, WidgetRef ref) { + return PlatformMenu( + label: '文件', + menus: [ + PlatformMenuItemGroup( + members: [ + PlatformMenuItem( + label: '新建笔记', + // 快捷键 Cmd+N 已在"闲言"菜单中注册,此处不重复注册 + onSelected: () => _navigateTo(AppRoutes.noteEdit), + ), + PlatformMenuItem( + label: '打开稍后阅读…', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyR, + meta: true, + ), + onSelected: () => _navigateTo(AppRoutes.readLater), + ), + ], + ), + PlatformMenuItemGroup( + members: [ + PlatformMenuItem( + label: '关闭窗口', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyW, + meta: true, + ), + onSelected: () => _closeWindow(), + ), + ], + ), + ], + ); + } + + // ============================================================ + // 编辑菜单(系统标准) + // ============================================================ + + PlatformMenu _buildEditMenu() { + return PlatformMenu( + label: '编辑', + menus: [ + PlatformMenuItemGroup( + members: [ + PlatformMenuItem( + label: '撤销', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyZ, + meta: true, + ), + onSelected: () => _undo(), + ), + PlatformMenuItem( + label: '重做', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyZ, + meta: true, + shift: true, + ), + onSelected: () => _redo(), + ), + ], + ), + PlatformMenuItemGroup( + members: [ + PlatformMenuItem( + label: '剪切', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyX, + meta: true, + ), + onSelected: () => _cut(), + ), + PlatformMenuItem( + label: '复制', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyC, + meta: true, + ), + onSelected: () => _copy(), + ), + PlatformMenuItem( + label: '粘贴', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyV, + meta: true, + ), + onSelected: () => _paste(), + ), + PlatformMenuItem( + label: '全选', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyA, + meta: true, + ), + onSelected: () => _selectAll(), + ), + ], + ), + ], + ); + } + + // ============================================================ + // 视图菜单 + // ============================================================ + + PlatformMenu _buildViewMenu( + BuildContext context, + WidgetRef ref, + bool isDark, + bool isWorkbench, + ) { + return PlatformMenu( + label: '视图', + menus: [ + const PlatformMenuItemGroup( + members: [ + PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.toggleFullScreen, + ), + ], + ), + PlatformMenuItemGroup( + members: [ + PlatformMenuItem( + label: '深色模式', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyD, + meta: true, + shift: true, + ), + onSelected: () => _toggleDarkMode(ref), + ), + PlatformMenuItem( + label: '工作台模式', + shortcut: const SingleActivator( + LogicalKeyboardKey.keyB, + meta: true, + ), + onSelected: () => _toggleWorkbench(ref), + ), + ], + ), + PlatformMenuItemGroup( + members: [ + PlatformMenuItem( + label: '每日拾句', + onSelected: () => _navigateTo(AppRoutes.home), + ), + PlatformMenuItem( + label: '句子广场', + onSelected: () => _navigateTo(AppRoutes.home), + ), + ], + ), + ], + ); + } + + // ============================================================ + // 窗口菜单(系统标准) + // ============================================================ + + PlatformMenu _buildWindowMenu() { + return const PlatformMenu( + label: '窗口', + menus: [ + PlatformMenuItemGroup( + members: [ + PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.minimizeWindow, + ), + PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.zoomWindow, + ), + ], + ), + PlatformMenuItemGroup( + members: [ + PlatformProvidedMenuItem( + type: PlatformProvidedMenuItemType.arrangeWindowsInFront, + ), + ], + ), + ], + ); + } + + // ============================================================ + // 帮助菜单 + // ============================================================ + + PlatformMenu _buildHelpMenu(BuildContext context, WidgetRef ref) { + return PlatformMenu( + label: '帮助', + menus: [ + PlatformMenuItemGroup( + members: [ + PlatformMenuItem( + label: '闲言帮助', + onSelected: () => _navigateTo(AppRoutes.about), + ), + ], + ), + PlatformMenuItemGroup( + members: [ + PlatformMenuItem( + label: '检查更新', + onSelected: () => _navigateTo(AppRoutes.appInfo), + ), + PlatformMenuItem( + label: '反馈问题', + onSelected: () => _navigateTo(AppRoutes.correction), + ), + ], + ), + ], + ); + } + + // ============================================================ + // 辅助方法 + // ============================================================ + + /// 导航到指定路由 + void _navigateTo(String route) { + try { + appRouter.push(route); + } catch (e) { + Log.e('MacosMenuBarWrapper._navigateTo 失败: $e'); + } + } + + /// 切换深色/浅色模式 + void _toggleDarkMode(WidgetRef ref) { + try { + final settings = ref.read(themeSettingsProvider); + final newMode = settings.isDark ? AppThemeMode.light : AppThemeMode.dark; + ref.read(themeSettingsProvider.notifier).setThemeMode(newMode); + } catch (e) { + Log.e('MacosMenuBarWrapper._toggleDarkMode 失败: $e'); + } + } + + /// 切换工作台模式 + void _toggleWorkbench(WidgetRef ref) { + try { + final current = ref.read(splitViewProvider).workbenchEnabled; + ref.read(splitViewProvider.notifier).setWorkbenchEnabled(!current); + } catch (e) { + Log.e('MacosMenuBarWrapper._toggleWorkbench 失败: $e'); + } + } + + /// 关闭窗口 + /// + /// macOS 上快捷键 Cmd+W 由系统自动处理,此方法作为菜单点击的 fallback。 + void _closeWindow() { + // 快捷键 Cmd+W 由系统自动处理,此方法仅作为菜单点击的 fallback + Log.d('MacosMenuBarWrapper._closeWindow: 由系统快捷键处理'); + } + + /// 撤销 + /// + /// macOS 上快捷键 Cmd+Z 由系统自动处理,此方法作为菜单点击的 fallback。 + void _undo() { + Log.d('MacosMenuBarWrapper._undo: 由系统快捷键处理'); + } + + /// 重做 + void _redo() { + Log.d('MacosMenuBarWrapper._redo: 由系统快捷键处理'); + } + + /// 剪切 + void _cut() { + Log.d('MacosMenuBarWrapper._cut: 由系统快捷键处理'); + } + + /// 复制 + void _copy() { + Log.d('MacosMenuBarWrapper._copy: 由系统快捷键处理'); + } + + /// 粘贴 + void _paste() { + Log.d('MacosMenuBarWrapper._paste: 由系统快捷键处理'); + } + + /// 全选 + void _selectAll() { + Log.d('MacosMenuBarWrapper._selectAll: 由系统快捷键处理'); + } +} diff --git a/lib/features/discover/presentation/pages/chat/chat_flow_page.dart b/lib/features/discover/presentation/pages/chat/chat_flow_page.dart index 63c38623..ec759f4d 100644 --- a/lib/features/discover/presentation/pages/chat/chat_flow_page.dart +++ b/lib/features/discover/presentation/pages/chat/chat_flow_page.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 会话流页面 // 创建时间: 2026-04-30 -// 更新时间: 2026-05-26 +// 更新时间: 2026-06-19 // 作用: 会话流Tab内容,对话式UI+用户输入+智能推送+附件发送+稍后读会话+全文搜索 -// 上次更新: 迁移readlaterRefreshStream至DataSyncEventBus统一事件总线 +// 上次更新: 修复Provider生命周期隐患——所有会话进入时主动reload消息和附件 // ============================================================ import 'dart:async'; @@ -91,6 +91,16 @@ class _ChatFlowPageState extends ConsumerState ref .read(chatSessionProvider.notifier) .updateSessionTime(widget.conversationId); + // 所有会话:每次进入页面主动从DB重新加载,确保展示最新数据 + //(chatMessagesProvider 是非 autoDispose,重复进入会复用旧状态) + ref + .read(chatMessagesProvider(widget.conversationId).notifier) + .reloadMessages(); + // 附件:每次进入页面主动刷新附件列表 + //(chatAttachmentProvider 是非 autoDispose,重复进入会复用旧状态) + ref + .read(chatAttachmentProvider(widget.conversationId).notifier) + .loadAttachments(); }); if (widget.isReadlater) { diff --git a/lib/features/discover/presentation/pages/chat/chat_settings_page.dart b/lib/features/discover/presentation/pages/chat/chat_settings_page.dart index 86cf6311..8379bc51 100644 --- a/lib/features/discover/presentation/pages/chat/chat_settings_page.dart +++ b/lib/features/discover/presentation/pages/chat/chat_settings_page.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 聊天设置页面 // 创建时间: 2026-05-08 -// 更新时间: 2026-05-26 +// 更新时间: 2026-06-19 // 作用: 聊天会话设置,背景图/导出导入/分类管理/回收站/同步等 -// 上次更新: v5.9.0 chatMessagesProvider改为family provider +// 上次更新: 分类emoji替换为CupertinoIcons渲染(love保留情感emoji) // ============================================================ import 'dart:io'; @@ -496,14 +496,6 @@ class _ChatSettingsPageState extends ConsumerState { 'literature', 'movie', ]; - final categoryEmojis = { - 'hot': '🔥', - 'love': '💕', - 'nature': '🌿', - 'motivate': '💪', - 'literature': '📖', - 'movie': '🎬', - }; final categoryNames = { 'hot': t.discover.categoryHot, 'love': t.discover.categoryLove, @@ -556,10 +548,7 @@ class _ChatSettingsPageState extends ConsumerState { ...defaultCategories.map( (cat) => _buildCupertinoListTile( ext, - leading: Text( - categoryEmojis[cat] ?? '🏷️', - style: const TextStyle(fontSize: 24), - ), + leading: _buildCategoryLeading(cat, ext), title: categoryNames[cat] ?? cat, trailing: Icon( CupertinoIcons.pencil, @@ -577,6 +566,26 @@ class _ChatSettingsPageState extends ConsumerState { ); } + /// 分类列表项前置图标 — love保留情感emoji,其余使用CupertinoIcons + Widget _buildCategoryLeading(String cat, AppThemeExtension ext) { + switch (cat) { + case 'hot': + return Icon(CupertinoIcons.flame, size: 24, color: ext.textSecondary); + case 'love': + return const Text('💕', style: TextStyle(fontSize: 24)); + case 'nature': + return Icon(CupertinoIcons.tree, size: 24, color: ext.textSecondary); + case 'motivate': + return Icon(CupertinoIcons.bolt, size: 24, color: ext.textSecondary); + case 'literature': + return Icon(CupertinoIcons.book, size: 24, color: ext.textSecondary); + case 'movie': + return Icon(CupertinoIcons.film, size: 24, color: ext.textSecondary); + default: + return Icon(CupertinoIcons.tag, size: 24, color: ext.textSecondary); + } + } + void _showEditCategoryDialog( AppThemeExtension ext, String categoryId, diff --git a/lib/features/discover/presentation/pages/chat/hidden_sessions_page.dart b/lib/features/discover/presentation/pages/chat/hidden_sessions_page.dart index b33be235..8c5a7540 100644 --- a/lib/features/discover/presentation/pages/chat/hidden_sessions_page.dart +++ b/lib/features/discover/presentation/pages/chat/hidden_sessions_page.dart @@ -56,7 +56,7 @@ class HiddenSessionsPage extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Text('👁️‍🗨️', style: TextStyle(fontSize: 56)), + Icon(CupertinoIcons.eye, size: 56, color: ext.textHint), const SizedBox(height: AppSpacing.md), Text( t.discover.noHiddenSessions, diff --git a/lib/features/discover/presentation/pages/content/category_detail_page.dart b/lib/features/discover/presentation/pages/content/category_detail_page.dart index da73c751..9bbda879 100644 --- a/lib/features/discover/presentation/pages/content/category_detail_page.dart +++ b/lib/features/discover/presentation/pages/content/category_detail_page.dart @@ -1,15 +1,16 @@ // ============================================================ // 闲言APP — 分类详情页 // 创建时间: 2026-04-29 -// 更新时间: 2026-05-24 +// 更新时间: 2026-06-19 // 作用: 某分类下的内容列表,支持下拉刷新+上拉加载 -// 上次更新: 改用FeedService.fetchList替代SearchAllService.fuzzy(空关键词被服务端拒绝) +// 上次更新: 返回按钮改用 context.appPop(),修复工作台模式下 Navigator.pop 弹空根栈导致白屏 // ============================================================ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/router/app_nav_extension.dart'; import 'package:xianyan/core/theme/app_theme.dart'; import 'package:xianyan/core/theme/app_spacing.dart'; import 'package:xianyan/core/theme/app_typography.dart'; @@ -134,7 +135,7 @@ class _CategoryDetailPageState extends ConsumerState { children: [ CupertinoButton( padding: EdgeInsets.zero, - onPressed: () => Navigator.pop(context), + onPressed: () => context.appPop(), child: Icon(CupertinoIcons.back, color: ext.textPrimary, size: 28), ), const SizedBox(width: AppSpacing.xs), diff --git a/lib/features/discover/presentation/pages/home/discover_page.dart b/lib/features/discover/presentation/pages/home/discover_page.dart index 5ff3bbe5..9df924c0 100644 --- a/lib/features/discover/presentation/pages/home/discover_page.dart +++ b/lib/features/discover/presentation/pages/home/discover_page.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 发现页面(联系人列表样式) // 创建时间: 2026-04-20 -// 更新时间: 2026-06-08 +// 更新时间: 2026-06-19 // 作用: 发现Tab — 会话流/工作流/足迹为独立对话条目,宽屏支持右侧面板 -// 上次更新: 添加置顶笔记会话点击导航到笔记编辑页 +// 上次更新: 修复Provider生命周期隐患——进入页面主动refresh会话列表 // ============================================================ import 'dart:math' as math; @@ -23,11 +23,10 @@ import 'package:xianyan/core/theme/app_radius.dart'; import 'package:xianyan/core/router/app_routes.dart'; import 'package:xianyan/core/providers/split_view_provider.dart'; import 'package:xianyan/core/layout/adaptive_split_view.dart'; -import 'package:xianyan/core/layout/right_panel_registry.dart'; +import 'package:xianyan/core/layout/workbench/right_panel_navigator.dart'; import 'package:xianyan/features/discover/models/chat_session.dart'; import 'package:xianyan/features/discover/providers/chat_session_provider.dart'; import 'package:xianyan/features/discover/providers/translate_provider.dart'; -import 'package:xianyan/features/discover/presentation/panels/chat_flow_panel.dart'; import 'package:xianyan/core/constants/character_expression.dart'; import 'package:xianyan/shared/widgets/animation/appbar_character_sprite.dart'; import 'package:xianyan/shared/widgets/animation/sprite_dialog_bubble.dart'; @@ -36,6 +35,7 @@ import 'package:xianyan/features/discover/providers/tool_center_provider.dart'; import 'package:xianyan/features/discover/presentation/widgets/session/session_row.dart'; import 'package:xianyan/features/discover/presentation/widgets/session/session_search_bar.dart'; import 'package:xianyan/features/discover/presentation/widgets/tool/tool_panel.dart'; +import 'package:xianyan/features/discover/presentation/widgets/tool/tool_center_right_panel.dart'; import 'package:xianyan/shared/widgets/adaptive/adaptive_back_button.dart'; import 'package:xianyan/shared/widgets/adaptive/keyboard_safe_sheet.dart'; import 'package:xianyan/shared/widgets/feedback/offline_banner.dart'; @@ -65,8 +65,11 @@ class _DiscoverPageState extends ConsumerState { @override void initState() { super.initState(); - RightPanelRegistry.register('chat_flow', (ctx, args) { - return ChatFlowPanel.fromArgs(args); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + // 会话列表:每次进入页面主动刷新,确保展示最新数据 + //(chatSessionProvider 是非 autoDispose,重复进入会复用旧状态) + ref.read(chatSessionProvider.notifier).refresh(); }); } @@ -75,6 +78,11 @@ class _DiscoverPageState extends ConsumerState { final ext = AppTheme.ext(context); final t = ref.watch(translationsProvider); final sessionState = ref.watch(chatSessionProvider); + // 工作台模式判断:宽屏 + 工作台开关开启 + final screenWidth = MediaQuery.sizeOf(context).width; + final splitState = ref.watch(splitViewProvider); + final isWorkbench = screenWidth >= kCompactBreakpoint && + splitState.workbenchEnabled; return CupertinoPageScaffold( backgroundColor: ext.bgPrimary, @@ -90,7 +98,8 @@ class _DiscoverPageState extends ConsumerState { Expanded(child: _buildSessionList(ext, t, sessionState)), ], ), - if (!pu.isIOS) const ToolPanel(), + // 工作台模式下不渲染 ToolPanel 覆盖层(工具中心走右栏) + if (!pu.isIOS && !isWorkbench) const ToolPanel(), ], ), ), @@ -118,7 +127,8 @@ class _DiscoverPageState extends ConsumerState { if (pu.isIOS) { showQuickActions(context, ref); } else { - ref.read(toolCenterProvider.notifier).openPanel(); + // 统一走 _openToolCenter(内部判断工作台模式) + _openToolCenter(); } }, child: Container( @@ -261,7 +271,26 @@ class _DiscoverPageState extends ConsumerState { } void _openToolCenter() { - ref.read(toolCenterProvider.notifier).openPanel(); + final screenWidth = MediaQuery.sizeOf(context).width; + final splitState = ref.read(splitViewProvider); + final isWorkbench = screenWidth >= kCompactBreakpoint && + splitState.workbenchEnabled; + + if (isWorkbench) { + // 工作台模式:push 工具中心到右栏嵌套 Navigator + // 使用 RightPanelEntry.builder 直接构建 ToolCenterRightPanel + ref.read(rightPanelStackProvider.notifier).push( + splitState.currentTab, + RightPanelEntry( + route: '/tool/center', + title: '工具中心', + builder: (_) => const ToolCenterRightPanel(), + ), + ); + } else { + // 窄屏模式:保持原有覆盖层方式 + ref.read(toolCenterProvider.notifier).openPanel(); + } } /// 直接导航到今日诗词工具页面 @@ -385,9 +414,39 @@ class _DiscoverPageState extends ConsumerState { ); } + /// 获取会话对应的路由路径(用于工作台模式选中态匹配) + String? _getSessionRoute(ChatSession session) { + switch (session.type) { + case ChatSessionType.chat: + return '${AppRoutes.chatFlow}/${session.id}'; + case ChatSessionType.discover: + return AppRoutes.inspiration; + case ChatSessionType.footprint: + return AppRoutes.footprint; + case ChatSessionType.readlater: + return AppRoutes.readlaterChat; + case ChatSessionType.custom: + if (session.isPinnedNote && session.linkedNoteId != null) { + return '${AppRoutes.noteEdit}?id=${session.linkedNoteId}'; + } + if (session.id == 'leisure') return AppRoutes.leisure; + if (session.id == 'rss_feed') return AppRoutes.rssReader; + return session.route; + } + } + Widget _buildSessionItem(AppThemeExtension ext, ChatSession session) { + // 工作台模式:监听右栏当前路由,高亮匹配的会话 + final activeRoute = ref.watch(activeRightPanelRouteProvider(1)); + final sessionRoute = _getSessionRoute(session); + final isSelected = activeRoute != null && + sessionRoute != null && + (activeRoute == sessionRoute || + activeRoute.startsWith('$sessionRoute/')); + return SessionRow( session: session, + isSelected: isSelected, onTap: () => _onSessionTap(session), onPin: () { ref.read(chatSessionProvider.notifier).togglePin(session.id); @@ -414,18 +473,10 @@ class _DiscoverPageState extends ConsumerState { // 更新会话使用时间,使其自动排到置顶下方最前 ref.read(chatSessionProvider.notifier).updateSessionTime(session.id); - final screenWidth = MediaQuery.sizeOf(context).width; - final splitState = ref.read(splitViewProvider); - final isWidescreen = - screenWidth >= kSplitViewBreakpoint && splitState.splitViewEnabled; - + // 工作台模式判断已下沉到 context.appPush,统一走 appPush switch (session.type) { case ChatSessionType.chat: - if (isWidescreen) { - _openChatFlowPanel(session); - } else { - context.appPush('${AppRoutes.chatFlow}/${session.id}'); - } + context.appPush('${AppRoutes.chatFlow}/${session.id}'); break; case ChatSessionType.discover: context.appPush(AppRoutes.inspiration); @@ -467,11 +518,7 @@ class _DiscoverPageState extends ConsumerState { } break; case ChatSessionType.readlater: - if (isWidescreen) { - _openChatFlowPanel(session); - } else { - context.appPush(AppRoutes.readlaterChat); - } + context.appPush(AppRoutes.readlaterChat); break; } } @@ -663,21 +710,6 @@ class _DiscoverPageState extends ConsumerState { ); } - /// 宽屏模式下在右侧面板打开会话流 - void _openChatFlowPanel(ChatSession session) { - final t = ref.read(translationsProvider); - ref - .read(splitViewProvider.notifier) - .setDiscoverRightPanel( - 'chat_flow', - args: { - 'conversationId': session.id, - 'sessionName': session.localizedName(t.discover.base), - 'sessionType': session.type.id, - }, - ); - } - void _showRemarkDialog(ChatSession session, AppThemeExtension ext) { final t = ref.read(translationsProvider); final controller = TextEditingController(text: session.remark ?? ''); diff --git a/lib/features/discover/presentation/pages/home/inspiration_detail_sheet.dart b/lib/features/discover/presentation/pages/home/inspiration_detail_sheet.dart index 3e4b66fd..16a7bf7a 100644 --- a/lib/features/discover/presentation/pages/home/inspiration_detail_sheet.dart +++ b/lib/features/discover/presentation/pages/home/inspiration_detail_sheet.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 灵感详情底部弹窗 // 创建时间: 2026-05-31 -// 更新时间: 2026-05-31 +// 更新时间: 2026-06-19 // 作用: 灵感详情底部弹窗 — 动态主题+动态圆角+分享/卡片生成/TTS朗读 -// 上次更新: 增加TTS朗读按钮,利用TtsService实现语音朗读 +// 上次更新: 修复TTS朗读与实际内容脱节——记录当前朗读文本,状态监听按文本匹配 // ============================================================ import 'dart:async'; @@ -150,13 +150,27 @@ class _DetailSheetContent extends StatefulWidget { class _DetailSheetContentState extends State<_DetailSheetContent> { StreamSubscription? _ttsSubscription; bool _isSpeaking = false; + /// 当前组件触发的朗读文本,用于区分是否是本组件在朗读 + String? _mySpokenText; @override void initState() { super.initState(); _ttsSubscription = TtsService.instance.onStateChanged.listen((state) { - final isSpeaking = - state == TtsState.speaking || state == TtsState.loading; + // 只有当当前朗读文本是本组件触发的,才更新 _isSpeaking + // 避免 TtsService 单例多组件共享状态流导致的脱节 + final ttsService = TtsService.instance; + final isMySpeech = _mySpokenText != null && + ttsService.currentText == _mySpokenText; + + final isSpeaking = isMySpeech && + (state == TtsState.speaking || state == TtsState.loading); + + // 朗读结束或被其他组件抢占时,清除本组件的朗读标记 + if (state == TtsState.idle || !isMySpeech) { + _mySpokenText = null; + } + if (isSpeaking != _isSpeaking && mounted) { setState(() => _isSpeaking = isSpeaking); } @@ -166,7 +180,8 @@ class _DetailSheetContentState extends State<_DetailSheetContent> { @override void dispose() { _ttsSubscription?.cancel(); - if (_isSpeaking) { + // 只有本组件在朗读时才停止,避免影响其他组件 + if (_isSpeaking && _mySpokenText != null) { TtsService.instance.stop(); } super.dispose(); @@ -381,14 +396,23 @@ class _DetailSheetContentState extends State<_DetailSheetContent> { } /// 切换TTS朗读状态 + /// + /// 修复:TtsService 是全局单例,多组件共享状态流。 + /// 调用 speak 前先 stop,并记录本组件触发的朗读文本, + /// 状态监听按文本匹配,确保 _isSpeaking 与实际朗读内容一致。 Future _toggleTts() async { final tts = TtsService.instance; if (_isSpeaking) { await tts.stop(); + _mySpokenText = null; + if (mounted) setState(() => _isSpeaking = false); } else { + // 先停止其他组件的朗读,确保只有本组件在朗读 + await tts.stop(); await tts.init(); final text = widget.sentence.text.cleanHtml; if (text.isNotEmpty) { + _mySpokenText = text; await tts.speak(text); Log.i('灵感朗读: ${widget.sentence.id}'); } diff --git a/lib/features/discover/presentation/panels/chat_flow_panel.dart b/lib/features/discover/presentation/panels/chat_flow_panel.dart index dfb65385..1abee2dd 100644 --- a/lib/features/discover/presentation/panels/chat_flow_panel.dart +++ b/lib/features/discover/presentation/panels/chat_flow_panel.dart @@ -109,7 +109,11 @@ class _ChatFlowPanelState extends ConsumerState { ), child: Row( children: [ - Text(isReadlater ? '📖' : '💬', style: const TextStyle(fontSize: 18)), + Icon( + isReadlater ? CupertinoIcons.book : CupertinoIcons.chat_bubble_2, + size: 18, + color: ext.textSecondary, + ), const SizedBox(width: AppSpacing.xs), Expanded( child: Text( diff --git a/lib/features/discover/presentation/widgets/chat/chat_flow_input_bar.dart b/lib/features/discover/presentation/widgets/chat/chat_flow_input_bar.dart index 04829682..3ee30baf 100644 --- a/lib/features/discover/presentation/widgets/chat/chat_flow_input_bar.dart +++ b/lib/features/discover/presentation/widgets/chat/chat_flow_input_bar.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 会话流输入栏组件 // 创建时间: 2026-05-15 -// 更新时间: 2026-05-26 +// 更新时间: 2026-06-19 // 作用: 输入栏、附件按钮、发送按钮、富文本按钮、快捷分类栏 -// 上次更新: v5.9.0 chatMessagesProvider改为family provider +// 上次更新: 将附件网格与文件预览占位符中的 emoji 替换为 CupertinoIcons // ============================================================ import 'dart:io'; @@ -204,7 +204,7 @@ class _ChatFlowInputBarState extends ConsumerState { context, items: [ AttachmentGridItem( - emoji: '🖼️', + icon: CupertinoIcons.photo, label: t.discover.attachmentGallery, gradientColors: [const Color(0xFF6C63FF), const Color(0xFF4ECDC4)], onTap: () { @@ -213,7 +213,7 @@ class _ChatFlowInputBarState extends ConsumerState { }, ), AttachmentGridItem( - emoji: '📷', + icon: CupertinoIcons.camera, label: t.discover.attachmentCamera, gradientColors: [const Color(0xFFf093fb), const Color(0xFFf5576c)], onTap: () { @@ -222,7 +222,7 @@ class _ChatFlowInputBarState extends ConsumerState { }, ), AttachmentGridItem( - emoji: '🎬', + icon: CupertinoIcons.film, label: t.discover.attachmentVideo, gradientColors: [const Color(0xFF43e97b), const Color(0xFF38f9d7)], onTap: () { @@ -231,7 +231,7 @@ class _ChatFlowInputBarState extends ConsumerState { }, ), AttachmentGridItem( - emoji: '🎙️', + icon: CupertinoIcons.mic, label: t.discover.attachmentAudio, gradientColors: [const Color(0xFFFF6B6B), const Color(0xFFFFB74D)], onTap: () { @@ -245,7 +245,7 @@ class _ChatFlowInputBarState extends ConsumerState { }, ), AttachmentGridItem( - emoji: '📄', + icon: CupertinoIcons.doc, label: t.discover.attachmentFile, gradientColors: [const Color(0xFF667eea), const Color(0xFF764ba2)], onTap: () { @@ -254,7 +254,7 @@ class _ChatFlowInputBarState extends ConsumerState { }, ), AttachmentGridItem( - emoji: '📍', + icon: CupertinoIcons.location_solid, label: t.discover.attachmentLocation, gradientColors: [const Color(0xFFfa709a), const Color(0xFFfee140)], onTap: () { @@ -270,7 +270,7 @@ class _ChatFlowInputBarState extends ConsumerState { }, ), AttachmentGridItem( - emoji: '🔗', + icon: CupertinoIcons.link, label: t.discover.attachmentLink, gradientColors: [const Color(0xFFa18cd1), const Color(0xFFfbc2eb)], onTap: () { @@ -292,7 +292,7 @@ class _ChatFlowInputBarState extends ConsumerState { }, ), AttachmentGridItem( - emoji: '✏️', + icon: CupertinoIcons.pencil, label: t.discover.attachmentRichText, gradientColors: [const Color(0xFFffecd2), const Color(0xFFfcb69f)], onTap: () { @@ -565,12 +565,12 @@ class _PendingAttachmentItem extends StatelessWidget { Widget _buildFilePlaceholder() { final isImage = attachment.fileType.startsWith('image/'); - final icon = isImage ? '🖼️' : '📄'; + final icon = isImage ? CupertinoIcons.photo : CupertinoIcons.doc; return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text(icon, style: const TextStyle(fontSize: 18)), + Icon(icon, size: 18, color: ext.textHint), const SizedBox(height: 2), Text( attachment.fileName.length > 6 diff --git a/lib/features/discover/presentation/widgets/chat/chat_flow_message_list.dart b/lib/features/discover/presentation/widgets/chat/chat_flow_message_list.dart index 5202492b..60b47d15 100644 --- a/lib/features/discover/presentation/widgets/chat/chat_flow_message_list.dart +++ b/lib/features/discover/presentation/widgets/chat/chat_flow_message_list.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 会话流消息列表组件 // 创建时间: 2026-05-15 -// 更新时间: 2026-05-26 +// 更新时间: 2026-06-19 // 作用: 消息列表展示、空状态占位、搜索高亮 -// 上次更新: v5.9.0 chatMessagesProvider改为family provider +// 上次更新: 空状态emoji替换为CupertinoIcons渲染 // ============================================================ import 'package:flutter/cupertino.dart'; @@ -125,7 +125,11 @@ class ChatFlowEmptyState extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text(isReadlater ? '📖' : '💬', style: const TextStyle(fontSize: 48)) + Icon( + isReadlater ? CupertinoIcons.book : CupertinoIcons.chat_bubble_2, + size: 48, + color: ext.textHint, + ) .animate(onPlay: (c) => c.repeat(reverse: true)) .scale( begin: const Offset(1, 1), diff --git a/lib/features/discover/presentation/widgets/chat/chat_flow_readlater_settings_helper.dart b/lib/features/discover/presentation/widgets/chat/chat_flow_readlater_settings_helper.dart index 89225d3f..5b2660d4 100644 --- a/lib/features/discover/presentation/widgets/chat/chat_flow_readlater_settings_helper.dart +++ b/lib/features/discover/presentation/widgets/chat/chat_flow_readlater_settings_helper.dart @@ -1,14 +1,15 @@ // ============================================================ // 闲言APP — 稍后读标签与文件夹管理辅助类 // 创建时间: 2026-05-26 -// 更新时间: 2026-05-26 +// 更新时间: 2026-06-19 // 作用: 稍后读会话的标签管理、文件夹管理弹窗及列表项组件 -// 上次更新: 从 chat_flow_readlater_mixin.dart 分流 +// 上次更新: 空状态emoji替换为CupertinoIcons渲染 // ============================================================ import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/theme/app_theme.dart'; import 'package:xianyan/core/theme/app_typography.dart'; import 'package:xianyan/features/discover/models/chat_message.dart'; import 'package:xianyan/features/discover/providers/chat_provider.dart'; @@ -72,7 +73,11 @@ class ChatFlowReadlaterSettingsHelper { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Text('🏷️', style: TextStyle(fontSize: 40)), + Icon( + CupertinoIcons.tag, + size: 40, + color: AppTheme.ext(context).textHint, + ), const SizedBox(height: 8), Text(t.noTags, style: AppTypography.footnote), const SizedBox(height: 4), @@ -265,7 +270,11 @@ class ChatFlowReadlaterSettingsHelper { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Text('📁', style: TextStyle(fontSize: 40)), + Icon( + CupertinoIcons.folder, + size: 40, + color: AppTheme.ext(context).textHint, + ), const SizedBox(height: 8), Text(t.noFolders, style: AppTypography.footnote), const SizedBox(height: 4), diff --git a/lib/features/discover/presentation/widgets/chat/chat_flow_readlater_sync_helper.dart b/lib/features/discover/presentation/widgets/chat/chat_flow_readlater_sync_helper.dart index be8d2032..908823e5 100644 --- a/lib/features/discover/presentation/widgets/chat/chat_flow_readlater_sync_helper.dart +++ b/lib/features/discover/presentation/widgets/chat/chat_flow_readlater_sync_helper.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 稍后读同步/AI/协作/设备辅助类 // 创建时间: 2026-05-26 -// 更新时间: 2026-05-26 +// 更新时间: 2026-06-19 // 作用: 稍后读会话的云端同步、AI摘要、共享协作、跨设备同步、桌面小组件、剪贴板监控 -// 上次更新: 从 chat_flow_readlater_mixin.dart 分流 +// 上次更新: 空状态emoji及共享列表标题emoji替换为CupertinoIcons渲染 // ============================================================ import 'package:flutter/cupertino.dart'; @@ -265,7 +265,18 @@ class ChatFlowReadlaterSyncHelper { showCupertinoDialog( context: context, builder: (dCtx) => CupertinoAlertDialog( - title: Text('👥 ${list.name}'), + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.group, + size: 16, + color: AppTheme.ext(context).textSecondary, + ), + const SizedBox(width: 4), + Text(list.name), + ], + ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/features/discover/presentation/widgets/chat/chat_flow_send_toast.dart b/lib/features/discover/presentation/widgets/chat/chat_flow_send_toast.dart index 118705a2..31b319ef 100644 --- a/lib/features/discover/presentation/widgets/chat/chat_flow_send_toast.dart +++ b/lib/features/discover/presentation/widgets/chat/chat_flow_send_toast.dart @@ -1,13 +1,14 @@ // ============================================================ // 闲言APP — 会话流发送Toast组件 // 创建时间: 2026-05-15 -// 更新时间: 2026-05-15 +// 更新时间: 2026-06-19 // 作用: 消息发送后的浮动提示Toast,支持分类显示 -// 上次更新: v5.4.0 从chat_flow_page.dart拆分 +// 上次更新: 分类emoji替换为CupertinoIcons渲染(love保留情感emoji) // ============================================================ import 'dart:ui'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -62,11 +63,9 @@ class _SendToastWidgetState extends ConsumerState Widget build(BuildContext context) { final ext = AppTheme.ext(context); final t = ref.read(translationsProvider).discover.chatFlow; - final catEmoji = widget.category != null - ? _categoryEmoji(widget.category!) - : ''; - final label = widget.category != null - ? '$catEmoji ${t.sentToCategory} ${widget.category!}' + final hasCategory = widget.category != null; + final label = hasCategory + ? '${t.sentToCategory} ${widget.category!}' : t.sent; return SlideTransition( @@ -94,6 +93,10 @@ class _SendToastWidgetState extends ConsumerState child: Row( mainAxisSize: MainAxisSize.min, children: [ + if (hasCategory) ...[ + _buildCategoryLeading(ext), + const SizedBox(width: 4), + ], Text( label, style: AppTypography.subhead.copyWith( @@ -110,15 +113,25 @@ class _SendToastWidgetState extends ConsumerState ); } - String _categoryEmoji(String category) { - return switch (category) { - 'hot' => '🔥', - 'love' => '💕', - 'nature' => '🌿', - 'motivate' => '💪', - 'literature' => '📖', - 'movie' => '🎬', - _ => '🏷️', - }; + /// 分类前置图标 — love保留情感emoji,其余使用CupertinoIcons + Widget _buildCategoryLeading(AppThemeExtension ext) { + final cat = widget.category; + if (cat == null) return const SizedBox.shrink(); + switch (cat) { + case 'hot': + return Icon(CupertinoIcons.flame, size: 14, color: ext.accent); + case 'love': + return const Text('💕', style: TextStyle(fontSize: 14)); + case 'nature': + return Icon(CupertinoIcons.tree, size: 14, color: ext.accent); + case 'motivate': + return Icon(CupertinoIcons.bolt, size: 14, color: ext.accent); + case 'literature': + return Icon(CupertinoIcons.book, size: 14, color: ext.accent); + case 'movie': + return Icon(CupertinoIcons.film, size: 14, color: ext.accent); + default: + return Icon(CupertinoIcons.tag, size: 14, color: ext.accent); + } } } diff --git a/lib/features/discover/presentation/widgets/chat_bubble/chat_document_bubble.dart b/lib/features/discover/presentation/widgets/chat_bubble/chat_document_bubble.dart index 1cc99d99..3bc674cb 100644 --- a/lib/features/discover/presentation/widgets/chat_bubble/chat_document_bubble.dart +++ b/lib/features/discover/presentation/widgets/chat_bubble/chat_document_bubble.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 文档卡片气泡 /// 创建时间: 2026-05-15 -/// 更新时间: 2026-05-30 +/// 更新时间: 2026-06-19 /// 作用: ChatFlowPage中显示文档消息的卡片气泡组件 -/// 上次更新: 本地文件打开添加确认弹窗 +/// 上次更新: emoji替换为CupertinoIcons,_DocTypeConfig改用iconData /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -24,12 +24,12 @@ import '../../../services/chat_file_service.dart'; class _DocTypeConfig { const _DocTypeConfig({ - required this.emoji, + required this.iconData, required this.color, required this.label, }); - final String emoji; + final IconData iconData; final Color color; final String label; } @@ -37,28 +37,28 @@ class _DocTypeConfig { _DocTypeConfig _getDocTypeConfig(String fileType) { if (fileType.contains('pdf')) { return const _DocTypeConfig( - emoji: '📕', + iconData: CupertinoIcons.book, color: Color(0xFFFF3B30), label: 'PDF', ); } if (fileType.contains('word') || fileType.contains('doc')) { return const _DocTypeConfig( - emoji: '📘', + iconData: CupertinoIcons.book, color: Color(0xFF007AFF), label: 'Word', ); } if (fileType.contains('excel') || fileType.contains('sheet')) { return const _DocTypeConfig( - emoji: '📗', + iconData: CupertinoIcons.book, color: Color(0xFF34C759), label: 'Excel', ); } if (fileType.contains('presentation') || fileType.contains('ppt')) { return const _DocTypeConfig( - emoji: '📙', + iconData: CupertinoIcons.book, color: Color(0xFFFF9500), label: 'PPT', ); @@ -66,20 +66,20 @@ _DocTypeConfig _getDocTypeConfig(String fileType) { if (fileType.contains('zip') || fileType.contains('rar') || fileType.contains('7z') || fileType.contains('tar')) { return const _DocTypeConfig( - emoji: '📦', + iconData: CupertinoIcons.archivebox, color: Color(0xFFAF52DE), label: '压缩包', ); } if (fileType.contains('text') || fileType.contains('txt')) { return const _DocTypeConfig( - emoji: '📝', + iconData: CupertinoIcons.doc_text, color: Color(0xFF8E8E93), label: 'TXT', ); } return const _DocTypeConfig( - emoji: '📄', + iconData: CupertinoIcons.doc, color: Color(0xFF8E8E93), label: '文件', ); @@ -121,7 +121,7 @@ class ChatDocumentBubble extends StatelessWidget { Row( mainAxisSize: MainAxisSize.min, children: [ - // 左侧emoji图标 + // 左侧文档类型图标 Container( width: 48, height: 48, @@ -130,9 +130,10 @@ class ChatDocumentBubble extends StatelessWidget { borderRadius: AppRadius.mdBorder, ), child: Center( - child: Text( - config.emoji, - style: const TextStyle(fontSize: 26), + child: Icon( + config.iconData, + size: 26, + color: config.color, ), ), ), @@ -193,7 +194,7 @@ class ChatDocumentBubble extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ _ActionButton( - emoji: '📂', + icon: CupertinoIcons.folder_open, label: '打开文件', ext: ext, onTap: () => _openFile( @@ -203,7 +204,7 @@ class ChatDocumentBubble extends StatelessWidget { ), const SizedBox(width: AppSpacing.sm), _ActionButton( - emoji: '↗️', + icon: CupertinoIcons.arrow_up_right_square, label: '分享文件', ext: ext, onTap: () => _shareFile(context, fileName), @@ -270,13 +271,13 @@ class ChatDocumentBubble extends StatelessWidget { class _ActionButton extends StatelessWidget { const _ActionButton({ - required this.emoji, + required this.icon, required this.label, required this.ext, required this.onTap, }); - final String emoji; + final IconData icon; final String label; final AppThemeExtension ext; final VoidCallback onTap; @@ -300,7 +301,7 @@ class _ActionButton extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(emoji, style: const TextStyle(fontSize: 14)), + Icon(icon, size: 14, color: ext.textSecondary), const SizedBox(width: 4), Text( label, diff --git a/lib/features/discover/presentation/widgets/chat_bubble/chat_file_bubble.dart b/lib/features/discover/presentation/widgets/chat_bubble/chat_file_bubble.dart index f59a5098..5fbf7bd0 100644 --- a/lib/features/discover/presentation/widgets/chat_bubble/chat_file_bubble.dart +++ b/lib/features/discover/presentation/widgets/chat_bubble/chat_file_bubble.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 文件消息气泡 /// 创建时间: 2026-05-08 -/// 更新时间: 2026-05-30 +/// 更新时间: 2026-06-19 /// 作用: 文件消息气泡组件,文件图标+文件名+大小+类型 -/// 上次更新: 本地文件打开添加确认弹窗 +/// 上次更新: emoji替换为CupertinoIcons,统一图标风格 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -28,7 +28,6 @@ class ChatFileBubble extends StatelessWidget { final attachment = message.attachments.firstOrNull; final fileName = attachment?.fileName ?? message.text; final fileSize = attachment?.displaySize ?? ''; - final fileIcon = attachment?.fileIcon ?? '📄'; final fileType = attachment?.fileType ?? ''; return GestureDetector( @@ -50,7 +49,11 @@ class ChatFileBubble extends StatelessWidget { borderRadius: BorderRadius.circular(AppRadius.sm), ), child: Center( - child: Text(fileIcon, style: const TextStyle(fontSize: 22)), + child: Icon( + CupertinoIcons.doc, + size: 22, + color: ext.textHint, + ), ), ), const SizedBox(width: AppSpacing.sm), diff --git a/lib/features/discover/presentation/widgets/chat_bubble/chat_image_bubble.dart b/lib/features/discover/presentation/widgets/chat_bubble/chat_image_bubble.dart index e9524c15..d66057ca 100644 --- a/lib/features/discover/presentation/widgets/chat_bubble/chat_image_bubble.dart +++ b/lib/features/discover/presentation/widgets/chat_bubble/chat_image_bubble.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 图片消息气泡 /// 创建时间: 2026-05-08 -/// 更新时间: 2026-05-09 +/// 更新时间: 2026-06-19 /// 作用: 图片消息气泡组件,支持缩略图+分辨率角标+全屏预览 -/// 上次更新: 修复相对路径未转绝对路径导致PathNotFoundException +/// 上次更新: emoji替换为CupertinoIcons,统一图标风格 /// ============================================================ import 'dart:io'; @@ -119,9 +119,10 @@ class _ChatImageBubbleState extends State { Widget _placeholder() { return Center( - child: Text( - '🖼️', - style: TextStyle(fontSize: 36, color: widget.ext.textHint), + child: Icon( + CupertinoIcons.photo, + size: 36, + color: widget.ext.textHint, ), ); } @@ -149,12 +150,25 @@ class _ImagePreviewPage extends StatelessWidget { backgroundColor: ext.bgPrimary.withValues(alpha: 0.85), border: null, leading: const AdaptiveBackButton(), - middle: Text( - '🖼️ 图片预览', - style: AppTypography.title3.copyWith( - color: ext.textPrimary, - fontWeight: FontWeight.w600, - ), + middle: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon( + CupertinoIcons.photo, + size: 18, + color: ext.textPrimary, + ), + ), + Text( + '图片预览', + style: AppTypography.title3.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), child: SafeArea( diff --git a/lib/features/discover/presentation/widgets/chat_bubble/chat_link_bubble.dart b/lib/features/discover/presentation/widgets/chat_bubble/chat_link_bubble.dart index bb2900ba..30e43f25 100644 --- a/lib/features/discover/presentation/widgets/chat_bubble/chat_link_bubble.dart +++ b/lib/features/discover/presentation/widgets/chat_bubble/chat_link_bubble.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 链接预览卡片气泡 /// 创建时间: 2026-05-15 -/// 更新时间: 2026-05-15 +/// 更新时间: 2026-06-19 /// 作用: 在ChatFlowPage中显示链接消息,含OG预览+操作按钮+异步OG抓取 -/// 上次更新: v5.3.0 新增OG元数据异步抓取,渲染后自动补全预览信息 +/// 上次更新: emoji替换为CupertinoIcons,统一图标风格 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -151,7 +151,7 @@ class _ChatLinkBubbleState extends State { ); } - /// 顶部🔗图标 + "链接"标签 + /// 顶部链接图标 + "链接"标签 Widget _buildHeader() { final siteLabel = _siteName ?? _sourceApp; return Padding( @@ -163,7 +163,11 @@ class _ChatLinkBubbleState extends State { ), child: Row( children: [ - const Text('🔗', style: TextStyle(fontSize: 14)), + Icon( + CupertinoIcons.link, + size: 14, + color: widget.ext.accent, + ), const SizedBox(width: AppSpacing.xs), Text( siteLabel != null && siteLabel.isNotEmpty @@ -207,9 +211,10 @@ class _ChatLinkBubbleState extends State { height: 120, color: widget.ext.bgSecondary, child: Center( - child: Text( - '🖼️', - style: TextStyle(fontSize: 28, color: widget.ext.textHint), + child: Icon( + CupertinoIcons.photo, + size: 28, + color: widget.ext.textHint, ), ), ), @@ -370,13 +375,13 @@ class _ChatLinkBubbleState extends State { child: Row( children: [ _buildActionButton( - emoji: '🌐', + icon: CupertinoIcons.globe, label: '打开链接', onTap: () => _launchUrl(url), ), const SizedBox(width: AppSpacing.sm), _buildActionButton( - emoji: '📋', + icon: CupertinoIcons.doc_on_clipboard, label: '复制链接', onTap: () => _copyUrl(url), ), @@ -387,7 +392,7 @@ class _ChatLinkBubbleState extends State { /// 操作按钮 Widget _buildActionButton({ - required String emoji, + required IconData icon, required String label, required VoidCallback onTap, }) { @@ -405,7 +410,7 @@ class _ChatLinkBubbleState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(emoji, style: const TextStyle(fontSize: 13)), + Icon(icon, size: 13, color: widget.ext.accent), const SizedBox(width: 4), Text( label, diff --git a/lib/features/discover/presentation/widgets/chat_bubble/chat_location_bubble.dart b/lib/features/discover/presentation/widgets/chat_bubble/chat_location_bubble.dart index c43f8b04..67d676e7 100644 --- a/lib/features/discover/presentation/widgets/chat_bubble/chat_location_bubble.dart +++ b/lib/features/discover/presentation/widgets/chat_bubble/chat_location_bubble.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 位置消息气泡 /// 创建时间: 2026-06-09 -/// 更新时间: 2026-06-09 +/// 更新时间: 2026-06-19 /// 作用: 位置消息气泡组件,显示位置名称/地址/地图占位/操作按钮 -/// 上次更新: 初始创建 +/// 上次更新: emoji替换为CupertinoIcons,统一图标风格 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -57,7 +57,7 @@ class ChatLocationBubble extends StatelessWidget { ); } - /// 顶部 📍 图标 + "位置" 标签 + /// 顶部位置图标 + "位置" 标签 Widget _buildHeader() { return Padding( padding: const EdgeInsets.fromLTRB( @@ -68,7 +68,11 @@ class ChatLocationBubble extends StatelessWidget { ), child: Row( children: [ - const Text('📍', style: TextStyle(fontSize: 14)), + Icon( + CupertinoIcons.location_solid, + size: 14, + color: ext.accent, + ), const SizedBox(width: AppSpacing.xs), Text( '位置', @@ -197,13 +201,13 @@ class ChatLocationBubble extends StatelessWidget { child: Row( children: [ _buildActionButton( - emoji: '📋', + icon: CupertinoIcons.doc_on_clipboard, label: '复制地址', onTap: _copyAddress, ), const SizedBox(width: AppSpacing.sm), _buildActionButton( - emoji: '🗺️', + icon: CupertinoIcons.map, label: '打开地图', onTap: () => _openMap(_name, _address), ), @@ -214,7 +218,7 @@ class ChatLocationBubble extends StatelessWidget { /// 操作按钮 — 与 chat_link_bubble 风格一致 Widget _buildActionButton({ - required String emoji, + required IconData icon, required String label, required VoidCallback onTap, }) { @@ -232,7 +236,7 @@ class ChatLocationBubble extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(emoji, style: const TextStyle(fontSize: 13)), + Icon(icon, size: 13, color: ext.accent), const SizedBox(width: 4), Text( label, diff --git a/lib/features/discover/presentation/widgets/chat_bubble/chat_sentence_card_bubble.dart b/lib/features/discover/presentation/widgets/chat_bubble/chat_sentence_card_bubble.dart index b83e3be5..0fc39036 100644 --- a/lib/features/discover/presentation/widgets/chat_bubble/chat_sentence_card_bubble.dart +++ b/lib/features/discover/presentation/widgets/chat_bubble/chat_sentence_card_bubble.dart @@ -1,14 +1,15 @@ /// ============================================================ /// 闲言APP — 稍后读句子卡片气泡 /// 创建时间: 2026-05-15 -/// 更新时间: 2026-06-05 +/// 更新时间: 2026-06-19 /// 作用: ChatFlowPage中显示从句子详情Sheet收藏的稍后读句子卡片 -/// 上次更新: Web兼容—getTemporaryDirectory添加kIsWeb保护 +/// 上次更新: emoji替换为CupertinoIcons,保留点赞收藏社交emoji /// ============================================================ import 'dart:io'; import 'dart:ui' as ui; +import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -121,12 +122,23 @@ class ChatSentenceCardBubble extends StatelessWidget { color: Colors.white.withValues(alpha: 0.2), borderRadius: AppRadius.mdBorder, ), - child: Text( - '💬 发现句子', - style: AppTypography.caption1.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + CupertinoIcons.chat_bubble_2, + size: 12, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + '发现句子', + style: AppTypography.caption1.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), if (feedName.isNotEmpty) ...[ @@ -196,7 +208,11 @@ class ChatSentenceCardBubble extends StatelessWidget { padding: const EdgeInsets.only(top: AppSpacing.xs), child: Row( children: [ - const Text('📖', style: TextStyle(fontSize: 12)), + Icon( + CupertinoIcons.book, + size: 12, + color: Colors.white.withValues(alpha: 0.6), + ), const SizedBox(width: AppSpacing.xs), Flexible( child: Text( @@ -217,20 +233,33 @@ class ChatSentenceCardBubble extends StatelessWidget { Widget _buildStats(int likeCount, int commentCount, int favoriteCount) { return Row( children: [ - _statItem('❤️', likeCount), + _statItem( + const Text('❤️', style: TextStyle(fontSize: 12)), + likeCount, + ), const SizedBox(width: AppSpacing.md), - _statItem('💬', commentCount), + _statItem( + Icon( + CupertinoIcons.chat_bubble_2, + size: 12, + color: Colors.white.withValues(alpha: 0.7), + ), + commentCount, + ), const SizedBox(width: AppSpacing.md), - _statItem('⭐', favoriteCount), + _statItem( + const Text('⭐', style: TextStyle(fontSize: 12)), + favoriteCount, + ), ], ); } - Widget _statItem(String emoji, int count) { + Widget _statItem(Widget icon, int count) { return Row( mainAxisSize: MainAxisSize.min, children: [ - Text(emoji, style: const TextStyle(fontSize: 12)), + icon, const SizedBox(width: 2), Text( '$count', @@ -246,15 +275,15 @@ class ChatSentenceCardBubble extends StatelessWidget { Widget _buildActions(BuildContext context) { return Row( children: [ - _actionButton('📖 已读', () { + _actionButton(CupertinoIcons.book, '已读', () { onTapSentence?.call(); }), const SizedBox(width: AppSpacing.sm), - _actionButton('🎨 制作', () { + _actionButton(CupertinoIcons.paintbrush, '制作', () { _captureAndShare(context); }), const SizedBox(width: AppSpacing.sm), - _actionButton('↗️ 分享', () { + _actionButton(CupertinoIcons.arrow_up_right_square, '分享', () { ShareSheet.show( context: context, data: ShareData( @@ -269,7 +298,7 @@ class ChatSentenceCardBubble extends StatelessWidget { ); } - Widget _actionButton(String label, VoidCallback onTap) { + Widget _actionButton(IconData icon, String label, VoidCallback onTap) { return GestureDetector( onTap: onTap, child: Container( @@ -281,12 +310,23 @@ class ChatSentenceCardBubble extends StatelessWidget { color: Colors.white.withValues(alpha: 0.2), borderRadius: AppRadius.mdBorder, ), - child: Text( - label, - style: AppTypography.caption1.copyWith( - color: Colors.white.withValues(alpha: 0.9), - fontWeight: FontWeight.w500, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 14, + color: Colors.white.withValues(alpha: 0.9), + ), + const SizedBox(width: 4), + Text( + label, + style: AppTypography.caption1.copyWith( + color: Colors.white.withValues(alpha: 0.9), + fontWeight: FontWeight.w500, + ), + ), + ], ), ), ); diff --git a/lib/features/discover/presentation/widgets/chat_bubble/chat_video_bubble.dart b/lib/features/discover/presentation/widgets/chat_bubble/chat_video_bubble.dart index a18af46c..6c80ddcb 100644 --- a/lib/features/discover/presentation/widgets/chat_bubble/chat_video_bubble.dart +++ b/lib/features/discover/presentation/widgets/chat_bubble/chat_video_bubble.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 视频消息气泡 /// 创建时间: 2026-05-08 -/// 更新时间: 2026-06-08 +/// 更新时间: 2026-06-19 /// 作用: 视频消息气泡组件,缩略图+播放按钮+时长+分辨率+全屏播放+压缩保存 -/// 上次更新: 移除独立长按菜单,视频操作合并至ChatBubble统一菜单 +/// 上次更新: emoji替换为CupertinoIcons,统一图标风格 /// ============================================================ import 'dart:io'; @@ -48,7 +48,7 @@ class ChatVideoBubble extends StatefulWidget { } } - AppToast.show('💾 正在压缩保存…'); + AppToast.show('正在压缩保存…'); final result = await VideoCompress.compressVideo( videoPath, @@ -243,7 +243,11 @@ class _ChatVideoBubbleState extends State { } } return Center( - child: Text('🎬', style: TextStyle(fontSize: 36, color: ext.textHint)), + child: Icon( + CupertinoIcons.film, + size: 36, + color: ext.textHint, + ), ); } @@ -315,12 +319,25 @@ class _VideoPlayerPageState extends State<_VideoPlayerPage> { backgroundColor: Colors.black.withValues(alpha: 0.85), border: null, leading: const AdaptiveBackButton(), - middle: Text( - '🎬 视频播放', - style: AppTypography.title3.copyWith( - color: CupertinoColors.white, - fontWeight: FontWeight.w600, - ), + middle: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.only(right: 4), + child: Icon( + CupertinoIcons.film, + size: 18, + color: CupertinoColors.white, + ), + ), + Text( + '视频播放', + style: AppTypography.title3.copyWith( + color: CupertinoColors.white, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), child: SafeArea(child: Center(child: _buildPlayer())), @@ -332,7 +349,11 @@ class _VideoPlayerPageState extends State<_VideoPlayerPage> { return Column( mainAxisSize: MainAxisSize.min, children: [ - const Text('⚠️', style: TextStyle(fontSize: 48)), + const Icon( + CupertinoIcons.exclamationmark_triangle, + size: 48, + color: CupertinoColors.white, + ), const SizedBox(height: 12), Text( '视频无法播放', diff --git a/lib/features/discover/presentation/widgets/chat_input/attachment_grid_sheet.dart b/lib/features/discover/presentation/widgets/chat_input/attachment_grid_sheet.dart index 915c32d2..2040eaf0 100644 --- a/lib/features/discover/presentation/widgets/chat_input/attachment_grid_sheet.dart +++ b/lib/features/discover/presentation/widgets/chat_input/attachment_grid_sheet.dart @@ -1,9 +1,9 @@ -/// ============================================================ +/// ============================================================ /// 闲言APP — 8宫格附件选择面板 /// 创建时间: 2026-05-08 -/// 更新时间: 2026-05-08 +/// 更新时间: 2026-06-19 /// 作用: 替换ActionSheet,提供图片/视频/文件/录音等8个入口 -/// 上次更新: 初始创建 +/// 上次更新: 将 AttachmentGridItem.emoji 字段替换为 IconData icon,统一使用 CupertinoIcons /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -15,14 +15,14 @@ import 'package:xianyan/core/theme/app_typography.dart'; class AttachmentGridItem { const AttachmentGridItem({ - required this.emoji, + required this.icon, required this.label, required this.gradientColors, this.isEnabled = true, this.onTap, }); - final String emoji; + final IconData icon; final String label; final List gradientColors; final bool isEnabled; @@ -123,9 +123,10 @@ class _GridCell extends StatelessWidget { borderRadius: BorderRadius.circular(AppRadius.lg), ), child: Center( - child: Text( - item.emoji, - style: const TextStyle(fontSize: 24), + child: Icon( + item.icon, + size: 28, + color: item.isEnabled ? ext.accent : ext.textDisabled, ), ), ), diff --git a/lib/features/discover/presentation/widgets/chat_input/link_input_sheet.dart b/lib/features/discover/presentation/widgets/chat_input/link_input_sheet.dart index 6293ce66..56eab412 100644 --- a/lib/features/discover/presentation/widgets/chat_input/link_input_sheet.dart +++ b/lib/features/discover/presentation/widgets/chat_input/link_input_sheet.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 链接输入面板 /// 创建时间: 2026-06-09 -/// 更新时间: 2026-06-09 +/// 更新时间: 2026-06-19 /// 作用: 链接输入面板,支持URL输入/剪贴板粘贴/OG预览/发送链接消息 -/// 上次更新: 初始创建 +/// 上次更新: 标题emoji替换为CupertinoIcons渲染 /// ============================================================ import 'dart:async'; @@ -264,9 +264,22 @@ class _LinkInputSheetState extends State { /// 标题 Widget _buildTitle(AppThemeExtension ext) { - return Text( - '🔗 分享链接', - style: AppTypography.headline.copyWith(color: ext.textPrimary), + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon( + CupertinoIcons.link, + size: 16, + color: ext.textPrimary, + ), + ), + Text( + '分享链接', + style: AppTypography.headline.copyWith(color: ext.textPrimary), + ), + ], ); } diff --git a/lib/features/discover/presentation/widgets/chat_input/location_input_sheet.dart b/lib/features/discover/presentation/widgets/chat_input/location_input_sheet.dart index 9bb69e5a..2b75c1f3 100644 --- a/lib/features/discover/presentation/widgets/chat_input/location_input_sheet.dart +++ b/lib/features/discover/presentation/widgets/chat_input/location_input_sheet.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 位置输入面板 /// 创建时间: 2026-06-09 -/// 更新时间: 2026-06-09 +/// 更新时间: 2026-06-19 /// 作用: 位置输入面板,支持手动输入位置名称/地址/快捷选择/发送位置消息 -/// 上次更新: 初始创建 +/// 上次更新: 标题及位置预设emoji替换为CupertinoIcons渲染 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -15,9 +15,14 @@ import 'package:xianyan/core/theme/app_typography.dart'; /// 位置快捷选项 class _LocationPreset { - const _LocationPreset({required this.emoji, required this.label}); + const _LocationPreset({this.icon, this.emoji, required this.label}); + + /// Cupertino图标(优先使用,无对应图标时为null) + final IconData? icon; + + /// emoji(无对应CupertinoIcons时使用) + final String? emoji; - final String emoji; final String label; } @@ -64,12 +69,12 @@ class _LocationInputSheetState extends State { /// 快捷位置列表 static const _presets = [ - _LocationPreset(emoji: '🏠', label: '家'), - _LocationPreset(emoji: '🏢', label: '公司'), + _LocationPreset(icon: CupertinoIcons.home, label: '家'), + _LocationPreset(icon: CupertinoIcons.building_2_fill, label: '公司'), _LocationPreset(emoji: '☕', label: '咖啡厅'), _LocationPreset(emoji: '🍽️', label: '餐厅'), - _LocationPreset(emoji: '🏥', label: '医院'), - _LocationPreset(emoji: '🛒', label: '商场'), + _LocationPreset(icon: CupertinoIcons.bandage, label: '医院'), + _LocationPreset(icon: CupertinoIcons.cart, label: '商场'), ]; @override @@ -232,7 +237,16 @@ class _LocationInputSheetState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(preset.emoji, style: const TextStyle(fontSize: 14)), + preset.icon != null + ? Icon( + preset.icon, + size: 14, + color: isSelected ? ext.accent : ext.textSecondary, + ) + : Text( + preset.emoji ?? '', + style: const TextStyle(fontSize: 14), + ), const SizedBox(width: AppSpacing.xs), Text( preset.label, diff --git a/lib/features/discover/presentation/widgets/chat_input/record_audio_sheet.dart b/lib/features/discover/presentation/widgets/chat_input/record_audio_sheet.dart index 7ce741da..179e93ff 100644 --- a/lib/features/discover/presentation/widgets/chat_input/record_audio_sheet.dart +++ b/lib/features/discover/presentation/widgets/chat_input/record_audio_sheet.dart @@ -1,9 +1,9 @@ -/// ============================================================ +/// ============================================================ /// 闲言APP — 录音Sheet /// 创建时间: 2026-05-08 -/// 更新时间: 2026-05-08 +/// 更新时间: 2026-06-19 /// 作用: 录音界面,波形可视化+时长计时+停止/发送控制 -/// 上次更新: 初始创建 +/// 上次更新: 标题emoji替换为CupertinoIcons渲染 /// ============================================================ import 'dart:async'; @@ -129,11 +129,23 @@ class _RecordAudioSheetState extends State { ), ), const SizedBox(height: AppSpacing.md), - Text( - '🎙️ 录音', - style: AppTypography.headline.copyWith( - color: ext.textPrimary, - ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.only(right: 4), + child: Icon( + CupertinoIcons.mic, + size: 16, + ), + ), + Text( + '录音', + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + ), + ), + ], ), const SizedBox(height: AppSpacing.lg), _buildWaveform(ext), diff --git a/lib/features/discover/presentation/widgets/chat_input/rich_text_editor_sheet.dart b/lib/features/discover/presentation/widgets/chat_input/rich_text_editor_sheet.dart index 733afb42..d0b67c2a 100644 --- a/lib/features/discover/presentation/widgets/chat_input/rich_text_editor_sheet.dart +++ b/lib/features/discover/presentation/widgets/chat_input/rich_text_editor_sheet.dart @@ -1,4 +1,4 @@ -/// ============================================================ +/// ============================================================ /// 闲言APP — 富文本编辑器Sheet /// 创建时间: 2026-05-09 /// 更新时间: 2026-05-09 @@ -97,9 +97,24 @@ class _RichTextEditorSheetState extends State { style: AppTypography.body.copyWith(color: ext.textHint), ), ), - Text( - '✏️ 富文本编辑', - style: AppTypography.headline.copyWith(color: ext.textPrimary), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon( + CupertinoIcons.pencil, + size: 16, + color: ext.textPrimary, + ), + ), + Text( + '富文本编辑', + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + ), + ), + ], ), CupertinoButton( onPressed: _send, diff --git a/lib/features/discover/presentation/widgets/session/session_row.dart b/lib/features/discover/presentation/widgets/session/session_row.dart index 6f599b3c..dbea08df 100644 --- a/lib/features/discover/presentation/widgets/session/session_row.dart +++ b/lib/features/discover/presentation/widgets/session/session_row.dart @@ -32,6 +32,7 @@ class SessionRow extends ConsumerWidget { required this.onToggleMute, this.onDelete, this.onHiddenSettings, + this.isSelected = false, }); final ChatSession session; @@ -44,6 +45,9 @@ class SessionRow extends ConsumerWidget { final VoidCallback? onDelete; final VoidCallback? onHiddenSettings; + /// 是否选中(工作台模式下高亮当前右栏对应的会话) + final bool isSelected; + @override Widget build(BuildContext context, WidgetRef ref) { final ext = AppTheme.ext(context); @@ -105,6 +109,9 @@ class SessionRow extends ConsumerWidget { minimumSize: Size.zero, pressedOpacity: 0.7, borderRadius: AppRadius.lgBorder, + color: isSelected + ? ext.accent.withValues(alpha: 0.1) + : null, onPressed: onTap, child: _buildRowContent(ext, baseT), ), diff --git a/lib/features/discover/presentation/widgets/tool/tool_bottom_bar.dart b/lib/features/discover/presentation/widgets/tool/tool_bottom_bar.dart index 05649fd4..0234e611 100644 --- a/lib/features/discover/presentation/widgets/tool/tool_bottom_bar.dart +++ b/lib/features/discover/presentation/widgets/tool/tool_bottom_bar.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 工具面板底部固定栏 /// 创建时间: 2026-04-26 -/// 更新时间: 2026-06-01 +/// 更新时间: 2026-06-19 /// 作用: 底部双按钮(更多工具/设置),覆盖全屏宽度 -/// 上次更新: 移除编辑布局按钮,保留更多工具和设置 +/// 上次更新: 设置按钮pop改用 context.appPop(),修复工作台模式下 Navigator.pop 弹空根栈导致白屏 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -61,7 +61,7 @@ class ToolBottomBar extends ConsumerWidget { label: ref.watch(translationsProvider).discover.toolSettings, color: ext.textSecondary, onTap: () { - Navigator.of(context).pop(); + context.appPop(); context.appPush(AppRoutes.toolCenterSettings); }, ), diff --git a/lib/features/discover/presentation/widgets/tool/tool_center_right_panel.dart b/lib/features/discover/presentation/widgets/tool/tool_center_right_panel.dart new file mode 100644 index 00000000..27a9c830 --- /dev/null +++ b/lib/features/discover/presentation/widgets/tool/tool_center_right_panel.dart @@ -0,0 +1,107 @@ +/// ============================================================ +/// 闲言APP — 工具中心右栏面板(工作台模式专用) +/// 创建时间: 2026-06-18 +/// 更新时间: 2026-06-18 +/// 作用: 工作台模式下右栏显示完整工具中心(复用 ToolPanelSectionsMixin) +/// 上次更新: 初始版本,复用现有工具网格UI,无覆盖层动画 +/// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:xianyan/core/theme/app_theme.dart'; +import 'package:xianyan/core/theme/app_typography.dart'; +import 'package:xianyan/l10n/translations.dart'; +import '../../../providers/tool_center_provider.dart'; +import 'tool_panel_drag_state.dart'; +import 'tool_panel_navigator.dart'; +import 'tool_panel_actions.dart'; +import 'tool_panel_sections.dart'; + +/// 工具中心右栏面板 — 工作台模式下显示完整工具中心 +/// +/// 复用 ToolPanelSectionsMixin 构建 UI,但不包含覆盖层动画。 +/// 工具点击通过 ToolNavigationHelper.navigateToTool 走 context.appPush, +/// 在工作台模式下自动 push 到右栏栈。 +class ToolCenterRightPanel extends ConsumerStatefulWidget { + const ToolCenterRightPanel({super.key}); + + @override + ConsumerState createState() => + _ToolCenterRightPanelState(); +} + +class _ToolCenterRightPanelState extends ConsumerState + with + ToolPanelNavigatorMixin, + ToolPanelActionsMixin, + ToolPanelSectionsMixin { + /// 拖拽状态(右栏模式下不启用拖拽删除,但 Mixin 需要此对象) + final _dragState = ToolPanelDragState(); + + @override + ToolPanelDragState get dragState => _dragState; + + /// 右栏模式下无覆盖层,关闭操作为空实现 + @override + void closePanelWithAnimation() { + // no-op: 右栏模式下工具中心不是覆盖层,无需关闭动画 + } + + @override + void dispose() { + _dragState.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ext = AppTheme.ext(context); + final state = ref.watch(toolCenterProvider); + final t = ref.watch(translationsProvider); + + return CupertinoPageScaffold( + backgroundColor: ext.bgPrimary, + child: SafeArea( + child: Column( + children: [ + // 标题栏 + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: ext.bgPrimary, + border: Border( + bottom: BorderSide( + color: ext.textHint.withValues(alpha: 0.1), + ), + ), + ), + child: Row( + children: [ + const Text('🛠️', style: TextStyle(fontSize: 20)), + const SizedBox(width: 8), + Text( + t.discover.toolCenter.toolCenter, + style: AppTypography.title2.copyWith( + color: ext.textPrimary, + ), + ), + ], + ), + ), + // 工具网格内容 + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 16), + child: buildContent(ext, state), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/discover/presentation/widgets/tool/tool_panel.dart b/lib/features/discover/presentation/widgets/tool/tool_panel.dart index 545051e6..60386974 100644 --- a/lib/features/discover/presentation/widgets/tool/tool_panel.dart +++ b/lib/features/discover/presentation/widgets/tool/tool_panel.dart @@ -53,7 +53,9 @@ class _ToolPanelState extends ConsumerState { void _showPanel(BuildContext context) { try { - Navigator.of(context).push(ToolPanelOverlayRoute()).whenComplete( + // 使用 rootNavigator 确保覆盖层在全屏显示(工作台模式下中栏 Navigator 只覆盖中栏区域) + Navigator.of(context, rootNavigator: true) + .push(ToolPanelOverlayRoute()).whenComplete( () { if (mounted) { _isShowing = false; @@ -161,7 +163,8 @@ class _ToolPanelAnimatedContentState @override void closePanelWithAnimation() { HapticFeedback.lightImpact(); - Navigator.of(context).pop(); + // 使用 rootNavigator 确保从正确的 Navigator pop(工作台模式下 push 到了 rootNavigator) + Navigator.of(context, rootNavigator: true).pop(); } /// Mixin 抽象方法实现 — 拖拽状态对象 diff --git a/lib/features/discover/providers/chat_session_provider.dart b/lib/features/discover/providers/chat_session_provider.dart index e3b346e1..c5f07108 100644 --- a/lib/features/discover/providers/chat_session_provider.dart +++ b/lib/features/discover/providers/chat_session_provider.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 会话列表状态管理 // 创建时间: 2026-05-01 -/// 更新时间: 2026-06-08 +/// 更新时间: 2026-06-19 /// 作用: 发现页面会话列表状态 — 置顶/隐藏/备注/已读/免打扰/搜索/使用时间持久化/笔记置顶 -/// 上次更新: 系统会话emoji替换为CupertinoIcons渲染,UI层优先使用systemIcon +/// 上次更新: 类型安全修复(int vs num): 时间戳/noteId 使用 SafeJson.parseInt // ============================================================ import 'dart:convert'; @@ -13,6 +13,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:xianyan/core/storage/database/app_database.dart'; import 'package:xianyan/core/storage/kv_storage.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import 'package:xianyan/core/router/app_routes.dart'; import 'package:xianyan/core/utils/logger.dart'; import 'package:xianyan/core/utils/safe_init_mixin.dart'; @@ -399,7 +400,7 @@ class ChatSessionNotifier extends Notifier final map = jsonDecode(raw) as Map; final updated = state.sessions.map((s) { if (map.containsKey(s.id) && map[s.id] != null) { - final millis = map[s.id] as int; + final millis = SafeJson.parseInt(map[s.id]); return s.copyWith( lastTime: DateTime.fromMillisecondsSinceEpoch(millis), ); @@ -724,7 +725,7 @@ class ChatSessionNotifier extends Notifier final entries = []; for (final item in list) { final map = item as Map; - final noteId = map['noteId'] as int; + final noteId = SafeJson.parseInt(map['noteId']); final title = map['title'] as String? ?? '无标题'; final preview = map['preview'] as String? ?? '📝 点击查看笔记'; entries.add(PinnedNotesCompanion.insert( diff --git a/lib/features/file_transfer/collaboration/screen_share/input_action.dart b/lib/features/file_transfer/collaboration/screen_share/input_action.dart index c162f54e..d203e8ad 100644 --- a/lib/features/file_transfer/collaboration/screen_share/input_action.dart +++ b/lib/features/file_transfer/collaboration/screen_share/input_action.dart @@ -1,12 +1,13 @@ // ============================================================ // 闲言APP — 屏幕共享输入动作模型 // 创建时间: 2026-05-14 -// 更新时间: 2026-05-14 +// 更新时间: 2026-06-19 // 作用: 屏幕共享受限远程输入 — 动作类型/热区定义/操作日志 -// 上次更新: 初始创建 +// 上次更新: 类型安全修复(int vs num): timestamp 使用 SafeJson.parseInt // ============================================================ import 'dart:ui'; +import 'package:xianyan/core/utils/safe_json.dart'; enum InputActionType { tap('tap', '点击', '👆'), @@ -84,7 +85,7 @@ class InputAction { ) : null, timestamp: json['timestamp'] != null - ? DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int) + ? DateTime.fromMillisecondsSinceEpoch(SafeJson.parseInt(json['timestamp'])) : DateTime.now(), ); } @@ -229,7 +230,7 @@ class InputActionLog { zoneId: json['zoneId'] as String?, action: InputActionType.fromId(json['action'] as String? ?? 'tap'), timestamp: json['timestamp'] != null - ? DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int) + ? DateTime.fromMillisecondsSinceEpoch(SafeJson.parseInt(json['timestamp'])) : DateTime.now(), ); } diff --git a/lib/features/file_transfer/collaboration/screen_share/screen_share_page.dart b/lib/features/file_transfer/collaboration/screen_share/screen_share_page.dart index 2ba5f05d..6a4caea9 100644 --- a/lib/features/file_transfer/collaboration/screen_share/screen_share_page.dart +++ b/lib/features/file_transfer/collaboration/screen_share/screen_share_page.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 屏幕共享观看页面 // 创建时间: 2026-05-14 -// 更新时间: 2026-05-20 +// 更新时间: 2026-06-19 // 作用: 屏幕共享观看界面 — 帧图像显示/WebRTC流显示/热区覆盖/远程输入/计时/授权 -// 上次更新: v14.32.0 修复观看端找不到结束按钮的问题,为观看端新增底部控制栏,增大导航栏结束按钮点击区域 +// 上次更新: 页面返回按钮和延迟pop改用 context.appPop()/appCanPop(),修复工作台模式下 Navigator.pop 弹空根栈导致白屏 // ============================================================ import 'package:flutter/cupertino.dart'; @@ -11,6 +11,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:xianyan/core/router/app_nav_extension.dart'; import 'package:xianyan/core/theme/app_radius.dart'; import 'package:xianyan/core/theme/app_spacing.dart'; import 'package:xianyan/core/theme/app_theme.dart'; @@ -507,8 +508,8 @@ class _ScreenSharePageState extends ConsumerState { ), ); Future.delayed(const Duration(milliseconds: 600), () { - if (mounted && Navigator.canPop(context)) { - Navigator.pop(context); + if (mounted && context.appCanPop()) { + context.appPop(); } }).catchError((_) {}); } @@ -720,7 +721,7 @@ class _ScreenSharePageState extends ConsumerState { CupertinoButton( color: ext.accent, borderRadius: AppRadius.lgBorder, - onPressed: () => Navigator.pop(context), + onPressed: () => context.appPop(), child: Text( '返回', style: AppTypography.body.copyWith(color: ext.textOnAccent), diff --git a/lib/features/file_transfer/models/cloud_cache_record.dart b/lib/features/file_transfer/models/cloud_cache_record.dart index df4e35ec..d9fcd985 100644 --- a/lib/features/file_transfer/models/cloud_cache_record.dart +++ b/lib/features/file_transfer/models/cloud_cache_record.dart @@ -1,11 +1,13 @@ // ============================================================ // 闲言APP — 云端暂存记录模型 // 创建时间: 2026-05-12 -// 更新时间: 2026-05-12 +// 更新时间: 2026-06-19 // 作用: 云端暂存文件记录 — 上传/下载/过期/加密状态管理 -// 上次更新: v11.1.0 初始版本 +// 上次更新: 类型安全修复(int vs num): fromJson 时间戳使用 SafeJson.parseInt // ============================================================ +import 'package:xianyan/core/utils/safe_json.dart'; + enum CloudCacheUploadStatus { pending('pending', '待上传', '⏳'), encrypting('encrypting', '加密中', '🔐'), @@ -193,21 +195,21 @@ class CloudCacheRecord { json['downloadStatus'] as String? ?? 'none', ), expiresAt: json['expiresAt'] != null - ? DateTime.fromMillisecondsSinceEpoch(json['expiresAt'] as int) + ? DateTime.fromMillisecondsSinceEpoch(SafeJson.parseInt(json['expiresAt'])) : null, uploadedAt: json['uploadedAt'] != null - ? DateTime.fromMillisecondsSinceEpoch(json['uploadedAt'] as int) + ? DateTime.fromMillisecondsSinceEpoch(SafeJson.parseInt(json['uploadedAt'])) : null, downloadedAt: json['downloadedAt'] != null - ? DateTime.fromMillisecondsSinceEpoch(json['downloadedAt'] as int) + ? DateTime.fromMillisecondsSinceEpoch(SafeJson.parseInt(json['downloadedAt'])) : null, ownerId: json['ownerId'] as String? ?? '', targetId: json['targetId'] as String?, createdAt: json['createdAt'] != null - ? DateTime.fromMillisecondsSinceEpoch(json['createdAt'] as int) + ? DateTime.fromMillisecondsSinceEpoch(SafeJson.parseInt(json['createdAt'])) : DateTime.now(), updatedAt: json['updatedAt'] != null - ? DateTime.fromMillisecondsSinceEpoch(json['updatedAt'] as int) + ? DateTime.fromMillisecondsSinceEpoch(SafeJson.parseInt(json['updatedAt'])) : null, ); } diff --git a/lib/features/file_transfer/models/transfer_task.dart b/lib/features/file_transfer/models/transfer_task.dart index d081f55f..494ce3c2 100644 --- a/lib/features/file_transfer/models/transfer_task.dart +++ b/lib/features/file_transfer/models/transfer_task.dart @@ -1,11 +1,12 @@ // ============================================================ // 闲言APP — 传输任务模型 // 创建时间: 2026-05-09 -// 更新时间: 2026-05-12 +// 更新时间: 2026-06-19 // 作用: 文件传输助手传输任务数据模型 — 进度/速度/状态/校验/断点续传 -// 上次更新: v11.0.0 新增断点续传字段(fileId/totalChunks/receivedChunks/retryCount等) +// 上次更新: 类型安全修复(int vs num): receivedChunks 列表元素使用 SafeJson.parseInt // ============================================================ +import 'package:xianyan/core/utils/safe_json.dart'; import 'transfer_enums.dart'; import 'transfer_device.dart'; @@ -288,7 +289,7 @@ class TransferTask { chunkSize: (json['chunkSize'] as num?)?.toInt() ?? 65536, totalChunks: (json['totalChunks'] as num?)?.toInt(), receivedChunks: (json['receivedChunks'] as List?) - ?.map((e) => e as int) + ?.map((e) => SafeJson.parseInt(e)) .toSet(), retryCount: (json['retryCount'] as num?)?.toInt() ?? 0, maxRetries: (json['maxRetries'] as num?)?.toInt() ?? 3, diff --git a/lib/features/file_transfer/presentation/pages/device_pairing_page.dart b/lib/features/file_transfer/presentation/pages/device_pairing_page.dart index d206b22c..392c0fef 100644 --- a/lib/features/file_transfer/presentation/pages/device_pairing_page.dart +++ b/lib/features/file_transfer/presentation/pages/device_pairing_page.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 设备配对页面 // 创建时间: 2026-05-09 -// 更新时间: 2026-06-12 +// 更新时间: 2026-06-19 // 作用: 设备配对 — 配对码/扫码/雷达/其他方式 + DegradationManager降级提示 -// 上次更新: 统一错误提示为AppToast,移除_showAlert +// 上次更新: 配对成功后页面pop改用 context.appPop(),修复工作台模式下 Navigator.pop 弹空根栈导致白屏 // ============================================================ import 'package:flutter/cupertino.dart'; @@ -11,6 +11,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:xianyan/core/router/app_nav_extension.dart'; import 'package:xianyan/core/theme/app_theme.dart'; import 'package:xianyan/core/theme/app_spacing.dart'; import 'package:xianyan/core/theme/app_typography.dart'; @@ -394,7 +395,7 @@ class _DevicePairingPageState extends ConsumerState .read(transferProvider.notifier) .pairWithManualIp(ip, port: port); if (!mounted) return; - Navigator.of(context).pop(); + context.appPop(); } catch (e) { if (!mounted) return; AppToast.showError('配对失败: ${e.toString()}'); diff --git a/lib/features/file_transfer/presentation/pages/qr_code_tab.dart b/lib/features/file_transfer/presentation/pages/qr_code_tab.dart index bbec0b31..4792df21 100644 --- a/lib/features/file_transfer/presentation/pages/qr_code_tab.dart +++ b/lib/features/file_transfer/presentation/pages/qr_code_tab.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 二维码配对Tab // 创建时间: 2026-05-19 -// 更新时间: 2026-06-12 +// 更新时间: 2026-06-19 // 作用: QR码生成与扫描配对流程 -// 上次更新: 统一错误提示为AppToast,移除_showAlert +// 上次更新: 扫码成功后页面pop改用 context.appPop(),修复工作台模式下 Navigator.pop 弹空根栈导致白屏 // ============================================================ import 'dart:async'; @@ -15,6 +15,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:qr_flutter/qr_flutter.dart'; +import 'package:xianyan/core/router/app_nav_extension.dart'; import 'package:xianyan/core/theme/app_theme.dart'; import 'package:xianyan/core/theme/app_spacing.dart'; import 'package:xianyan/core/theme/app_typography.dart'; @@ -247,7 +248,7 @@ class _QrCodeTabState extends ConsumerState { setState(() => _isProcessingQr = false); if (mounted) { - Navigator.of(context).pop(); // 关闭扫码页面 + context.appPop(); // 关闭扫码页面 // 显示设备详情确认弹窗 _showDeviceDetailAndNavigate(alias, ip, port, url); } @@ -267,7 +268,7 @@ class _QrCodeTabState extends ConsumerState { if (isLanTransfer) { setState(() => _isProcessingQr = false); if (mounted) { - Navigator.of(context).pop(); // 关闭扫码页面 + context.appPop(); // 关闭扫码页面 Navigator.of(context).push( CupertinoPageRoute( builder: (_) => const FileTransferPage(), @@ -287,7 +288,7 @@ class _QrCodeTabState extends ConsumerState { if (payload.startsWith('xianyan://transfer')) { setState(() => _isProcessingQr = false); if (mounted) { - Navigator.of(context).pop(); // 关闭扫码页面 + context.appPop(); // 关闭扫码页面 Navigator.of(context).push( CupertinoPageRoute( builder: (_) => const FileTransferPage(), diff --git a/lib/features/file_transfer/services/cloud_cache_service.dart b/lib/features/file_transfer/services/cloud_cache_service.dart index 2745aa57..e777a598 100644 --- a/lib/features/file_transfer/services/cloud_cache_service.dart +++ b/lib/features/file_transfer/services/cloud_cache_service.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 云端暂存服务 // 创建时间: 2026-05-12 -// 更新时间: 2026-05-14 +// 更新时间: 2026-06-19 // 作用: 云端暂存文件加密上传/下载解密/密钥管理/过期清理/ECDH密钥协商 -// 上次更新: v11.7.3 集成ECDH密钥协商,uploadFile/downloadFile优先使用ECDH共享密钥 +// 上次更新: 类型安全修复(int vs num): expiresAt 时间戳使用 SafeJson.parseInt // ============================================================ import 'dart:convert'; @@ -13,6 +13,7 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; @@ -256,7 +257,7 @@ class CloudCacheService { final data = response.data?['data'] as Map? ?? {}; final cacheId = data['cacheId'] as String? ?? record.id; final expiresAt = data['expiresAt'] != null - ? DateTime.fromMillisecondsSinceEpoch(data['expiresAt'] as int) + ? DateTime.fromMillisecondsSinceEpoch(SafeJson.parseInt(data['expiresAt'])) : DateTime.now().add(Duration(hours: expireHours)); final cloudUrl = data['cloudUrl'] as String?; diff --git a/lib/features/file_transfer/services/discovery/usb_discovery_service.dart b/lib/features/file_transfer/services/discovery/usb_discovery_service.dart index 98e775b1..adc18276 100644 --- a/lib/features/file_transfer/services/discovery/usb_discovery_service.dart +++ b/lib/features/file_transfer/services/discovery/usb_discovery_service.dart @@ -25,7 +25,7 @@ class UsbDiscoveryService { final UsbTransportService _usbService; static const EventChannel _usbEventChannel = EventChannel( - 'xianyan/usb_events', + 'apps.xy.xianyan/usb_events', ); bool _isMonitoring = false; diff --git a/lib/features/file_transfer/services/signaling_service.dart b/lib/features/file_transfer/services/signaling_service.dart index 4eab6602..6fa69b64 100644 --- a/lib/features/file_transfer/services/signaling_service.dart +++ b/lib/features/file_transfer/services/signaling_service.dart @@ -1,10 +1,10 @@ // ============================================================ // 闲言APP — WebSocket信令+消息中转服务 // 创建时间: 2026-05-09 -// 更新时间: 2026-05-20 +// 更新时间: 2026-06-19 // 作用: WebRTC信令中转+文本消息互发+设备发现+协议协商+WebSocket中转 // 参考 SnapDrop WebSocket 中转 + LocalSend Signaling 协议 -// 上次更新: v14.31.0 connect/updateUserId支持传入deviceModel替代Platform.localHostname +// 上次更新: 类型安全修复(int vs num): 时间戳使用 SafeJson.parseInt // ============================================================ import 'dart:async'; @@ -13,6 +13,7 @@ import 'dart:io'; import 'dart:math'; import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import 'package:xianyan/core/utils/logger.dart'; import 'package:xianyan/features/file_transfer/services/transfer_api_service.dart'; @@ -132,7 +133,7 @@ class SignalingMessage { to: json['to'] as String?, payload: payload, timestamp: json['ts'] != null - ? DateTime.fromMillisecondsSinceEpoch(json['ts'] as int) + ? DateTime.fromMillisecondsSinceEpoch(SafeJson.parseInt(json['ts'])) : null, ); } @@ -159,7 +160,7 @@ class DeviceOnlineEvent { ? TransferDevice.fromSignaling(json['device'] as Map) : null, timestamp: json['ts'] != null - ? DateTime.fromMillisecondsSinceEpoch(json['ts'] as int) + ? DateTime.fromMillisecondsSinceEpoch(SafeJson.parseInt(json['ts'])) : null, ); } diff --git a/lib/features/file_transfer/services/transport/usb_transport_service.dart b/lib/features/file_transfer/services/transport/usb_transport_service.dart index 26fe4bf9..02e20b5b 100644 --- a/lib/features/file_transfer/services/transport/usb_transport_service.dart +++ b/lib/features/file_transfer/services/transport/usb_transport_service.dart @@ -79,7 +79,7 @@ class UsbTransportService { final LocalSendService? localSendService; - static const MethodChannel _channel = MethodChannel('xianyan/usb_transport'); + static const MethodChannel _channel = MethodChannel('apps.xy.xianyan/usb_transport'); bool _isUsbConnected = false; bool get isUsbConnected => _isUsbConnected; diff --git a/lib/features/home/favorite_repository.dart b/lib/features/home/favorite_repository.dart index 4dc9fd8d..e1b85d60 100644 --- a/lib/features/home/favorite_repository.dart +++ b/lib/features/home/favorite_repository.dart @@ -1,15 +1,16 @@ // ============================================================ // 闲言APP — 收藏数据仓库 // 创建时间: 2026-05-30 -// 更新时间: 2026-06-12 +// 更新时间: 2026-06-19 // 作用: 统一收藏数据访问层,封装Feed API + Legacy API + 本地收藏三数据源 -// 上次更新: mergeWithLocalDb改为content+title联合去重,避免不同作者同内容被误去重 +// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0 // ============================================================ import 'dart:convert'; import 'package:drift/drift.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../../core/storage/kv_storage.dart'; import '../../../core/storage/database/app_database.dart'; @@ -60,9 +61,9 @@ class FavoriteItem { factory FavoriteItem.fromJson(Map json) { return FavoriteItem( - id: json['id'] as int? ?? 0, + id: SafeJson.parseInt(json['id']), targetType: json['target_type'] as String? ?? '', - targetId: json['target_id'] as int? ?? 0, + targetId: SafeJson.parseInt(json['target_id']), title: json['title'] as String? ?? '', content: json['content'] as String? ?? '', createtime: json['createtime']?.toString() ?? '', @@ -206,7 +207,7 @@ class FavoriteRepository { targetType: 'article', page: page, ); - final total = result['total'] as int? ?? 0; + final total = SafeJson.parseInt(result['total']); final list = (result['list'] as List? ?? []) .map((e) => FavoriteItem.fromJson(e as Map)) .toList(); @@ -385,6 +386,9 @@ class FavoriteRepository { } /// 合并服务端和本地数据库收藏,去重(以 content+author 为联合key) + /// + /// 本地条目追加到尾部(而非插入头部),避免取消收藏后本地缓存条目 + /// 仍然显示在列表顶部导致"取消收藏后数据仍在"的问题。 List mergeWithLocalDb( List serverItems, List localItems, @@ -396,7 +400,7 @@ class FavoriteRepository { final merged = List.from(serverItems); for (final local in localItems) { if (!existingKeys.contains(mergeKey(local))) { - merged.insert(0, local); + merged.add(local); } } return merged; @@ -670,7 +674,7 @@ class FavoriteRepository { action: FavoriteAction.count, ); final raw = result['counts'] as Map? ?? {}; - return raw.map((k, v) => MapEntry(k, v as int? ?? 0)); + return raw.map((k, v) => MapEntry(k, SafeJson.parseInt(v))); } catch (e) { Log.e('获取收藏统计失败', e); return {}; diff --git a/lib/features/home/presentation/anonymous_submit_page.dart b/lib/features/home/presentation/anonymous_submit_page.dart index d2a030b5..16c5050b 100644 --- a/lib/features/home/presentation/anonymous_submit_page.dart +++ b/lib/features/home/presentation/anonymous_submit_page.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 匿名投稿页面 /// 创建时间: 2026-06-13 -/// 更新时间: 2026-06-13 +/// 更新时间: 2026-06-19 /// 作用: 匿名投稿 — 填写标题/分类/内容/作者,提交后审核中,记录仅本地保存 -/// 上次更新: 初始创建 +/// 上次更新: 类型安全修复(int vs num): fromMap 使用 SafeJson.parseInt /// ============================================================ import 'dart:convert'; @@ -11,6 +11,7 @@ import 'dart:convert'; import 'package:flutter/cupertino.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_spacing.dart'; @@ -66,8 +67,8 @@ class SubmitRecord { content: map['content'] as String, title: map['title'] as String?, author: map['author'] as String?, - status: SubmitStatus.values[map['status'] as int], - createdAt: DateTime.fromMillisecondsSinceEpoch(map['createdAt'] as int), + status: SubmitStatus.values[SafeJson.parseInt(map['status'])], + createdAt: DateTime.fromMillisecondsSinceEpoch(SafeJson.parseInt(map['createdAt'])), ); } diff --git a/lib/features/home/presentation/home_page.dart b/lib/features/home/presentation/home_page.dart index 5912c943..7f3639de 100644 --- a/lib/features/home/presentation/home_page.dart +++ b/lib/features/home/presentation/home_page.dart @@ -42,11 +42,12 @@ import 'widgets/home_sentence_list_section.dart'; import 'widgets/quick_card_sheet.dart'; import 'widgets/home_app_bar_section.dart'; import 'widgets/home_system_state_monitor.dart'; +import 'widgets/new_features_dialog.dart'; import 'providers/sentence_detail_sheet.dart'; import '../../../../core/providers/split_view_provider.dart'; import '../../../../core/layout/adaptive_split_view.dart'; +import '../../../../core/layout/workbench/right_panel_navigator.dart'; import 'panels/sentence_detail_panel.dart'; -import '../../../../core/layout/right_panel_registry.dart'; import 'date_config_sheet.dart'; class HomePage extends ConsumerStatefulWidget { @@ -76,12 +77,10 @@ class _HomePageState extends ConsumerState { ); _readingController = ReadingExperienceController(); - RightPanelRegistry.register('sentence_detail', (ctx, args) { - return SentenceDetailPanel.fromArgs(args); - }); - WidgetsBinding.instance.addPostFrameCallback((_) { _setupStateListeners(); + // 引导页开启"了解新功能"后,首次进入主页弹出当前版本更新日志 + _maybeShowNewFeaturesDialog(); }); _scrollController.addListener(_onScrollForReading); } @@ -102,6 +101,14 @@ class _HomePageState extends ConsumerState { _readingController.onScroll(mode); } + /// 引导页开启"了解新功能"后,首次进入主页弹出当前版本更新日志 + void _maybeShowNewFeaturesDialog() { + if (!mounted) return; + final t = ref.read(translationsProvider); + final ext = Theme.of(context).extension()!; + NewFeaturesDialog.maybeShow(context, t, ext); + } + void _onChannelSwitch(String? code) { if (_isSwitchingChannel) return; setState(() => _isSwitchingChannel = true); @@ -152,16 +159,22 @@ class _HomePageState extends ConsumerState { ) { final screenWidth = MediaQuery.sizeOf(context).width; final splitState = ref.read(splitViewProvider); - final isWidescreen = - screenWidth >= kSplitViewBreakpoint && splitState.splitViewEnabled; + final isWorkbench = screenWidth >= kCompactBreakpoint && + splitState.workbenchEnabled; - if (isWidescreen) { - ref - .read(splitViewProvider.notifier) - .setHomeRightPanel( - 'sentence_detail', - args: {'sentenceId': sentence.id, 'sentenceText': sentence.text}, - ); + if (isWorkbench) { + // 工作台模式:push 到右栏嵌套 Navigator,显示完整句子详情面板 + ref.read(rightPanelStackProvider.notifier).push( + splitState.currentTab, + RightPanelEntry( + route: '/home/sentence/${sentence.id}', + title: '句子详情', + extra: {'sentenceId': sentence.id, 'sentenceText': sentence.text}, + builder: (_) => SentenceDetailPanel( + sentence: sentence, + ), + ), + ); return; } diff --git a/lib/features/home/presentation/providers/readlater/readlater_provider.dart b/lib/features/home/presentation/providers/readlater/readlater_provider.dart index 20f4fe34..30331990 100644 --- a/lib/features/home/presentation/providers/readlater/readlater_provider.dart +++ b/lib/features/home/presentation/providers/readlater/readlater_provider.dart @@ -1,9 +1,11 @@ /// ============================================================ /// 闲言APP — 稍后读数据状态管理 /// 创建时间: 2026-05-31 -/// 更新时间: 2026-06-09 +/// 更新时间: 2026-06-19 /// 作用: 稍后读数据加载、合并、离线缓存等业务逻辑 -/// 上次更新: UI状态(searchQuery/filterType/sortBy/selectedIds)迁移至ReadLaterUiNotifier, +/// 上次更新: 批量已读操作持久化到DB(toggleRead/markAllRead/markSelectedRead +/// 乐观更新内存后异步调用 ChatMessageService.markIsRead 持久化), +/// UI状态(searchQuery/filterType/sortBy/selectedIds)迁移至ReadLaterUiNotifier, /// 批量方法改为接受selectedIds参数,新增组合Provider /// ============================================================ @@ -588,20 +590,51 @@ class ReadLaterNotifier extends Notifier { // ============================================================ /// 切换条目已读状态 + /// + /// 乐观更新:先更新内存状态(UI 立即响应),再异步持久化到数据库。 + /// 注意:ChatMessageService 仅支持"标记已读"(markIsRead), + /// 不支持"标记未读"。因此: + /// - 未读→已读:调用 markIsRead 持久化 + /// - 已读→未读:仅更新内存,不持久化(DB 中仍为已读) void toggleRead(String id) { + final target = state.entries.firstWhere( + (e) => e.id == id, + orElse: () => const ReadLaterEntry( + id: '', + type: ReadLaterEntryType.text, + title: '', + subtitle: '', + ), + ); + final willBeRead = !target.isRead; + // 乐观更新内存状态 final entries = state.entries.map((e) { if (e.id == id) return e.copyWith(isRead: !e.isRead); return e; }).toList(); state = state.copyWith(entries: entries); + // 仅在 未读→已读 方向持久化(DB 不支持反向操作) + if (willBeRead) { + _persistMarkRead(id); + } } /// 全部标记已读 + /// + /// 乐观更新:先更新内存状态,再并行持久化所有未读条目到数据库。 void markAllRead() { + // 收集需要持久化的未读条目 id(仅 chatMessage 类型可持久化到 DB) + final unreadIds = state.entries + .where((e) => !e.isRead && e.chatMessage != null) + .map((e) => e.id) + .toList(); + // 乐观更新内存状态 final entries = state.entries.map((e) { return e.copyWith(isRead: true); }).toList(); state = state.copyWith(entries: entries); + // 并行持久化 + _persistMarkReadBatch(unreadIds); } // ============================================================ @@ -631,12 +664,59 @@ class ReadLaterNotifier extends Notifier { } /// 批量标记选中条目为已读 + /// + /// 乐观更新:先更新内存状态,再并行持久化选中条目中未读的到数据库。 void markSelectedRead(Set selectedIds) { + // 收集需要持久化的未读条目 id(仅 chatMessage 类型可持久化到 DB) + final unreadIds = state.entries + .where( + (e) => + selectedIds.contains(e.id) && + !e.isRead && + e.chatMessage != null, + ) + .map((e) => e.id) + .toList(); + // 乐观更新内存状态 final entries = state.entries.map((e) { if (selectedIds.contains(e.id)) return e.copyWith(isRead: true); return e; }).toList(); state = state.copyWith(entries: entries); + // 并行持久化 + _persistMarkReadBatch(unreadIds); + } + + // ============================================================ + // 已读状态持久化辅助方法(乐观更新策略) + // ============================================================ + + /// 持久化单条已读状态到数据库 + /// + /// 异步执行,失败时仅记录日志(Log.w),不阻塞 UI、不回滚内存状态。 + void _persistMarkRead(String id) { + Future(() async { + try { + await ChatMessageService.markIsRead(id); + } catch (e) { + Log.w('持久化已读状态失败: id=$id', e); + } + }); + } + + /// 批量持久化已读状态到数据库 + /// + /// 使用 Future.wait 并行执行多个 markIsRead 调用, + /// 失败时仅记录日志(Log.w),不阻塞 UI、不回滚内存状态。 + void _persistMarkReadBatch(List ids) { + if (ids.isEmpty) return; + Future(() async { + try { + await Future.wait(ids.map((id) => ChatMessageService.markIsRead(id))); + } catch (e) { + Log.w('批量持久化已读状态失败: count=${ids.length}', e); + } + }); } // ============================================================ diff --git a/lib/features/home/presentation/providers/readlater/tray_unread_count_provider.dart b/lib/features/home/presentation/providers/readlater/tray_unread_count_provider.dart new file mode 100644 index 00000000..2faa979e --- /dev/null +++ b/lib/features/home/presentation/providers/readlater/tray_unread_count_provider.dart @@ -0,0 +1,99 @@ +/// ============================================================ +/// 闲言APP — 托盘未读数 Provider +/// 创建时间: 2026-06-18 +/// 更新时间: 2026-06-18 +/// 作用: 合并稍后阅读 + 每日拾句的未读数,供托盘角标使用 +/// 上次更新: 初始创建,实现 trayUnreadCountProvider + dailySentenceUnreadCountProvider +/// ============================================================ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../../core/services/desktop/daily_sentence_viewed_service.dart'; +import '../../../../../core/utils/logger.dart'; +import '../../../providers/home_provider.dart'; +import 'readlater_provider.dart'; + +/// 每日拾句未读数 Provider +/// +/// 计算逻辑: +/// - 监听 homeProvider 的 dailySentences 列表 +/// - 过滤出未查看的条目(不在 DailySentenceViewedService.getViewedIds() 中) +/// - 返回未读数量 +/// +/// 注意:每日拾句列表通常为 5 条,未读数范围 0-5。 +final dailySentenceUnreadCountProvider = Provider((ref) { + final homeState = ref.watch(homeProvider); + final dailySentences = homeState.dailySentences; + + if (dailySentences.isEmpty) { + // 列表为空时,检查当前的 dailySentence 是否未查看 + final current = homeState.dailySentence; + if (current == null) return 0; + return DailySentenceViewedService.isViewed(current.id) ? 0 : 1; + } + + final viewedIds = DailySentenceViewedService.getViewedIds(); + final unread = dailySentences.where((s) => !viewedIds.contains(s.id)).length; + return unread; +}); + +/// 稍后阅读未读数 Provider +/// +/// 直接从 readLaterProvider 获取 unreadCount。 +final readLaterUnreadCountProvider = Provider((ref) { + final readLaterState = ref.watch(readLaterProvider); + return readLaterState.unreadCount; +}); + +/// 托盘未读总数 Provider +/// +/// 合并稍后阅读未读数 + 每日拾句未读数。 +/// 用于托盘角标显示。 +/// +/// 监听变化时自动更新,调用方可通过 ref.listen 响应变化。 +final trayUnreadCountProvider = Provider((ref) { + final readLaterUnread = ref.watch(readLaterUnreadCountProvider); + final dailySentenceUnread = ref.watch(dailySentenceUnreadCountProvider); + final total = readLaterUnread + dailySentenceUnread; + + if (total > 0) { + Log.d( + '托盘未读数: 稍后阅读=$readLaterUnread, 每日拾句=$dailySentenceUnread, 总计=$total', + ); + } + + return total; +}); + +/// 每日拾句已查看状态变化通知 Provider +/// +/// 当每日拾句列表变化时,自动将当前查看的句子标记为已查看。 +/// 调用方需要在每日拾句被用户查看时调用 markCurrentDailySentenceViewed。 +final dailySentenceViewedNotifierProvider = + NotifierProvider<_DailySentenceViewedNotifier, void>( + _DailySentenceViewedNotifier.new, +); + +class _DailySentenceViewedNotifier extends Notifier { + @override + void build() { + // 监听每日拾句变化,自动标记当前 dailySentence 为已查看 + ref.listen(homeProvider.select((s) => s.dailySentence?.id), (previous, id) { + if (id != null && id.isNotEmpty) { + DailySentenceViewedService.markViewed(id).catchError((Object e) { + Log.e('自动标记每日拾句已查看失败: $e'); + }); + } + }); + } + + /// 手动标记指定 id 为已查看 + Future markViewed(String id) async { + await DailySentenceViewedService.markViewed(id); + } + + /// 批量标记已查看 + Future markViewedBatch(Iterable ids) async { + await DailySentenceViewedService.markViewedBatch(ids); + } +} diff --git a/lib/features/home/presentation/providers/readlater_page.dart b/lib/features/home/presentation/providers/readlater_page.dart index 65de2c4d..44da6e6a 100644 --- a/lib/features/home/presentation/providers/readlater_page.dart +++ b/lib/features/home/presentation/providers/readlater_page.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 稍后读列表页面 /// 创建时间: 2026-04-28 -/// 更新时间: 2026-06-16 +/// 更新时间: 2026-06-19 /// 作用: 展示用户稍后读列表,支持搜索/筛选/排序/批量操作/富详情 -/// 上次更新: 移除自动剪贴板检查,改为用户主动点击按钮触发 +/// 上次更新: 修复Provider生命周期隐患——进入页面主动loadItems作为事件总线兜底 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -55,6 +55,12 @@ class _ReadLaterPageState extends ConsumerState void initState() { super.initState(); // 剪贴板检查已改为用户主动点击触发,此处不再自动检查 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + // 稍后读:每次进入页面主动刷新,作为事件总线的兜底 + //(readLaterProvider 是非 autoDispose,重复进入会复用旧状态) + ref.read(readLaterProvider.notifier).loadItems(refresh: true); + }); } @override diff --git a/lib/features/home/presentation/widgets/new_features_dialog.dart b/lib/features/home/presentation/widgets/new_features_dialog.dart new file mode 100644 index 00000000..009c1f32 --- /dev/null +++ b/lib/features/home/presentation/widgets/new_features_dialog.dart @@ -0,0 +1,605 @@ +/// ============================================================ +/// 闲言APP — 新版本功能弹窗(图文卡片轮播) +/// 创建时间: 2026-06-19 +/// 更新时间: 2026-06-19 +/// 作用: 引导页开启"了解新功能"后,进入主页弹出当前版本更新日志 +/// 上次更新: 重构为图文卡片轮播,支持图标匹配+动态主题色+手势滑动 +/// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart' show Material, MaterialType, PageView, Colors; + +import '../../../../core/constants/app_constants.dart'; +import '../../../../core/storage/kv_storage.dart'; +import '../../../../core/theme/app_spacing.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/theme/app_typography.dart'; +import '../../../../core/theme/app_radius.dart'; +import '../../../../core/utils/logger.dart'; +import '../../../../l10n/types/t.dart'; +import '../../../profile/presentation/about_shared_widgets.dart'; + +/// 新版本功能弹窗 +/// 在主页首次进入时调用 [maybeShow],若用户在引导页开启了"了解新功能" +/// 且当前版本未查看过,则弹出更新日志(图文卡片轮播)。 +class NewFeaturesDialog { + NewFeaturesDialog._(); + + /// 检查并显示新功能弹窗(仅显示一次 per version) + /// [t] 当前翻译实例,[ext] 当前主题扩展 + /// 返回 true 表示已弹出 + static Future maybeShow( + BuildContext context, + T t, + AppThemeExtension ext, + ) async { + try { + // 检查开关是否开启 + if (!KvStorage.knowNewFeatures) return false; + + final currentVersion = AppVersion.version; + final lastSeen = KvStorage.lastSeenVersion; + + // 已查看过此版本,不再弹出 + if (lastSeen == currentVersion) return false; + + if (!context.mounted) return false; + + // 查找当前版本的更新日志 + final entry = _findEntryForVersion(currentVersion); + if (entry == null) { + // 无对应日志,仍标记为已查看避免重复检查 + await KvStorage.setLastSeenVersion(currentVersion); + return false; + } + + await _showDialog(context, entry, t, ext); + await KvStorage.setLastSeenVersion(currentVersion); + return true; + } catch (e, st) { + Log.e('NewFeaturesDialog: maybeShow error', e, st); + return false; + } + } + + /// 查找当前版本的更新日志条目 + /// 优先精确匹配,其次匹配 major.minor,最后返回最新条目 + static UpdateLogEntry? _findEntryForVersion(String version) { + if (AppUpdateLog.entries.isEmpty) return null; + + // 1. 精确匹配(去掉 v 前缀比较) + final cleanVer = version.startsWith('v') ? version.substring(1) : version; + for (final e in AppUpdateLog.entries) { + final entryVer = + e.version.startsWith('v') ? e.version.substring(1) : e.version; + if (entryVer == cleanVer) return e; + } + + // 2. major.minor 匹配(如 6.6.20 匹配 v6.6.x) + final parts = cleanVer.split('.'); + if (parts.length >= 2) { + final majorMinor = '${parts[0]}.${parts[1]}'; + for (final e in AppUpdateLog.entries) { + final entryVer = + e.version.startsWith('v') ? e.version.substring(1) : e.version; + if (entryVer.startsWith('$majorMinor.')) return e; + } + } + + // 3. 返回最新条目 + return AppUpdateLog.entries.first; + } + + /// 显示弹窗(图文卡片轮播) + static Future _showDialog( + BuildContext context, + UpdateLogEntry entry, + T t, + AppThemeExtension ext, + ) async { + await showCupertinoModalPopup( + context: context, + builder: (ctx) { + return _NewFeaturesCarousel( + entry: entry, + t: t, + ext: ext, + ); + }, + ); + } +} + +/// ============================================================ +/// 图文卡片轮播组件 +/// ============================================================ + +class _NewFeaturesCarousel extends StatefulWidget { + const _NewFeaturesCarousel({ + required this.entry, + required this.t, + required this.ext, + }); + + final UpdateLogEntry entry; + final T t; + final AppThemeExtension ext; + + @override + State<_NewFeaturesCarousel> createState() => _NewFeaturesCarouselState(); +} + +class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> { + late final PageController _pageController; + int _currentPage = 0; + + @override + void initState() { + super.initState(); + _pageController = PageController(); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + /// 解析变更条目,生成卡片数据列表 + List<_FeatureCardData> _buildCards() { + final cards = <_FeatureCardData>[]; + + // 第一张卡片:版本概览 + cards.add(_FeatureCardData( + icon: CupertinoIcons.sparkles, + iconColor: widget.ext.accent, + title: '${widget.entry.version} 更新', + subtitle: widget.entry.date, + description: widget.t.onboarding.knowNewFeatures + .replaceAll('{0}', AppVersion.version), + isOverview: true, + )); + + // 后续卡片:每个变更点 + for (final change in widget.entry.changes) { + final parsed = _parseChange(change); + cards.add(_FeatureCardData( + icon: parsed.icon, + iconColor: parsed.color, + title: parsed.title, + description: parsed.description, + )); + } + + return cards; + } + + /// 解析单条变更文本,提取图标、标题、描述 + _ParsedChange _parseChange(String change) { + final trimmed = change.trim(); + + // 提取开头 emoji(如果有) + String? emoji; + String rest = trimmed; + if (trimmed.isNotEmpty) { + // 匹配常见 emoji 前缀 + final emojiMatch = RegExp(r'^([\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}✨🔧🆕🎨⚡🌐📱🔒🎯✅🐛🚀💡📦🔥⭐💕🎉]+)\s*').firstMatch(trimmed); + if (emojiMatch != null) { + emoji = emojiMatch.group(1); + rest = trimmed.substring(emojiMatch.end).trim(); + } + } + + // 根据 emoji 或关键词匹配图标和颜色 + final match = _matchIcon(emoji, rest); + return _ParsedChange( + icon: match.icon, + color: match.color, + title: _extractTitle(rest), + description: _extractDescription(rest), + ); + } + + /// 根据 emoji 或关键词匹配图标和颜色 + _IconMatch _matchIcon(String? emoji, String text) { + final ext = widget.ext; + + // 按 emoji 匹配 + if (emoji != null) { + if (emoji.contains('🆕') || emoji.contains('🎉')) { + return _IconMatch(CupertinoIcons.plus_circle_fill, ext.successColor); + } + if (emoji.contains('🔧') || emoji.contains('🐛')) { + return _IconMatch(CupertinoIcons.wrench_fill, ext.warningColor); + } + if (emoji.contains('🎨')) { + return _IconMatch(CupertinoIcons.paintbrush_fill, ext.iconTintPurple); + } + if (emoji.contains('⚡')) { + return _IconMatch(CupertinoIcons.bolt_fill, ext.iconTintYellow); + } + if (emoji.contains('🌐')) { + return _IconMatch(CupertinoIcons.globe, ext.iconTintBlue); + } + if (emoji.contains('🔒') || emoji.contains('🛡')) { + return _IconMatch(CupertinoIcons.lock_shield_fill, ext.errorColor); + } + if (emoji.contains('📱')) { + return _IconMatch(CupertinoIcons.device_phone_portrait, ext.iconTintCyan); + } + if (emoji.contains('🚀') || emoji.contains('✨')) { + return _IconMatch(CupertinoIcons.sparkles, ext.accent); + } + if (emoji.contains('💡')) { + return _IconMatch(CupertinoIcons.lightbulb_fill, ext.iconTintYellow); + } + if (emoji.contains('🔥')) { + return _IconMatch(CupertinoIcons.flame_fill, ext.errorColor); + } + if (emoji.contains('⭐') || emoji.contains('💕')) { + return _IconMatch(CupertinoIcons.heart_fill, ext.iconTintViolet); + } + if (emoji.contains('📦')) { + return _IconMatch(CupertinoIcons.cube_box_fill, ext.iconTintMint); + } + if (emoji.contains('🎯') || emoji.contains('✅')) { + return _IconMatch(CupertinoIcons.checkmark_seal_fill, ext.successColor); + } + } + + // 按关键词匹配 + final lower = text.toLowerCase(); + if (text.contains('新增') || text.contains('增加') || lower.contains('add')) { + return _IconMatch(CupertinoIcons.plus_circle_fill, ext.successColor); + } + if (text.contains('修复') || lower.contains('fix') || lower.contains('bug')) { + return _IconMatch(CupertinoIcons.wrench_fill, ext.warningColor); + } + if (text.contains('优化') || text.contains('改进') || text.contains('提升') || lower.contains('optim')) { + return _IconMatch(CupertinoIcons.paintbrush_fill, ext.iconTintPurple); + } + if (text.contains('性能') || lower.contains('performance')) { + return _IconMatch(CupertinoIcons.bolt_fill, ext.iconTintYellow); + } + if (text.contains('多语言') || text.contains('翻译') || lower.contains('language')) { + return _IconMatch(CupertinoIcons.globe, ext.iconTintBlue); + } + if (text.contains('安全') || text.contains('密保') || lower.contains('security')) { + return _IconMatch(CupertinoIcons.lock_shield_fill, ext.errorColor); + } + if (text.contains('框架') || text.contains('架构') || lower.contains('framework')) { + return _IconMatch(CupertinoIcons.cube_box_fill, ext.iconTintMint); + } + + // 默认 + return _IconMatch(CupertinoIcons.sparkles, ext.accent); + } + + /// 提取标题(取第一行或前20字) + String _extractTitle(String text) { + final lines = text.split('\n'); + final firstLine = lines.first.trim(); + if (firstLine.length <= 30) return firstLine; + return '${firstLine.substring(0, 30)}...'; + } + + /// 提取描述(剩余内容) + String _extractDescription(String text) { + final lines = text.split('\n'); + if (lines.length > 1) { + return lines.skip(1).join('\n').trim(); + } + final firstLine = lines.first.trim(); + if (firstLine.length > 30) { + return firstLine.substring(30).trim(); + } + return ''; + } + + void _onPageChanged(int page) { + setState(() { + _currentPage = page; + }); + } + + @override + Widget build(BuildContext context) { + final cards = _buildCards(); + final ext = widget.ext; + final screenHeight = MediaQuery.of(context).size.height; + final maxWidth = MediaQuery.of(context).size.width * 0.92; + + return Material( + type: MaterialType.transparency, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: Container( + constraints: BoxConstraints( + maxWidth: maxWidth, + maxHeight: screenHeight * 0.75, + ), + decoration: BoxDecoration( + color: ext.bgCard, + borderRadius: BorderRadius.circular(AppRadius.xl), + boxShadow: [ + BoxShadow( + color: ext.overlayStrong, + blurRadius: 40, + offset: const Offset(0, 20), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.xl), + child: Column( + // mainAxisSize.max 让 Column 填充 maxHeight 约束 + // 配合 Expanded 让 PageView 占据剩余空间,避免内容溢出 + children: [ + // 顶部拖拽指示器 + _buildDragHandle(ext), + // 卡片轮播区域(Expanded 占据剩余空间) + Expanded( + child: PageView.builder( + controller: _pageController, + onPageChanged: _onPageChanged, + itemCount: cards.length, + itemBuilder: (ctx, index) { + return _buildCard(cards[index], ext, index == 0); + }, + ), + ), + // 底部页码指示器 + 按钮 + _buildBottomBar(cards.length, ext), + ], + ), + ), + ), + ), + ); + } + + /// 构建拖拽指示器 + Widget _buildDragHandle(AppThemeExtension ext) { + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.sm, bottom: AppSpacing.xs), + child: Container( + width: 36, + height: 5, + decoration: BoxDecoration( + color: ext.overlayMedium, + borderRadius: BorderRadius.circular(2.5), + ), + ), + ); + } + + /// 构建单张卡片 + Widget _buildCard(_FeatureCardData card, AppThemeExtension ext, bool isOverview) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 图标区域(带光晕效果) + _buildIconWithGlow(card, ext, isOverview), + const SizedBox(height: AppSpacing.lg), + // 标题 + Text( + card.title, + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + // 副标题(日期) + if (card.subtitle != null) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + card.subtitle!, + style: AppTypography.caption1.copyWith(color: ext.textHint), + textAlign: TextAlign.center, + ), + ], + // 描述 + if (card.description.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.md), + Text( + card.description, + style: AppTypography.body.copyWith( + color: ext.textSecondary, + height: 1.5, + ), + textAlign: TextAlign.center, + maxLines: 6, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ); + } + + /// 构建带光晕效果的图标 + Widget _buildIconWithGlow( + _FeatureCardData card, AppThemeExtension ext, bool isOverview) { + final iconSize = isOverview ? 64.0 : 56.0; + final containerSize = isOverview ? 120.0 : 100.0; + + return Container( + width: containerSize, + height: containerSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + card.iconColor.withValues(alpha: 0.25), + card.iconColor.withValues(alpha: 0.08), + Colors.transparent, + ], + stops: const [0.0, 0.6, 1.0], + ), + ), + child: Center( + child: Container( + width: containerSize * 0.6, + height: containerSize * 0.6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: card.iconColor.withValues(alpha: 0.15), + border: Border.all( + color: card.iconColor.withValues(alpha: 0.3), + width: 1.5, + ), + ), + child: Icon( + card.icon, + size: iconSize, + color: card.iconColor, + ), + ), + ), + ); + } + + /// 构建底部栏(页码指示器 + 按钮) + Widget _buildBottomBar(int totalPages, AppThemeExtension ext) { + final isLastPage = _currentPage == totalPages - 1; + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + decoration: BoxDecoration( + color: ext.bgCard, + border: Border( + top: BorderSide(color: ext.overlaySubtle, width: 0.5), + ), + ), + child: SafeArea( + top: false, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // 跳过按钮 + CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () => Navigator.of(context).pop(), + child: Text( + widget.t.common.cancel, + style: AppTypography.body.copyWith(color: ext.textHint), + ), + ), + // 页码指示器(Flexible 包裹,避免卡片多时挤压两侧按钮导致溢出) + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: List.generate(totalPages, (index) { + final isActive = index == _currentPage; + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric(horizontal: 3), + width: isActive ? 20 : 6, + height: 6, + decoration: BoxDecoration( + color: isActive ? ext.accent : ext.overlayMedium, + borderRadius: BorderRadius.circular(3), + ), + ); + }), + ), + ), + ), + // 下一步/知道了按钮 + CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () { + if (isLastPage) { + Navigator.of(context).pop(); + } else { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: ext.accent, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Text( + isLastPage + ? widget.t.common.gotIt + : widget.t.common.confirm, + style: AppTypography.body.copyWith( + color: ext.textOnAccent, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// ============================================================ +/// 数据模型 +/// ============================================================ + +class _FeatureCardData { + const _FeatureCardData({ + required this.icon, + required this.iconColor, + required this.title, + required this.description, + this.subtitle, + this.isOverview = false, + }); + + final IconData icon; + final Color iconColor; + final String title; + final String description; + final String? subtitle; + final bool isOverview; +} + +class _ParsedChange { + const _ParsedChange({ + required this.icon, + required this.color, + required this.title, + required this.description, + }); + + final IconData icon; + final Color color; + final String title; + final String description; +} + +class _IconMatch { + const _IconMatch(this.icon, this.color); + + final IconData icon; + final Color color; +} diff --git a/lib/features/home/providers/home_interaction_mixin.dart b/lib/features/home/providers/home_interaction_mixin.dart index 06a5350f..b0891a58 100644 --- a/lib/features/home/providers/home_interaction_mixin.dart +++ b/lib/features/home/providers/home_interaction_mixin.dart @@ -239,6 +239,7 @@ mixin HomeInteractionMixin on Notifier { ) async { if (!oldValue) { // 添加稍后读:写入ChatMessage表 + bool inserted = false; try { await ChatMessageService.sendReadLaterSentence( conversationId: 'readlater', @@ -251,10 +252,19 @@ mixin HomeInteractionMixin on Notifier { views: sentence.views, sentenceId: sentence.id.toString(), ); - notifyReadlaterRefresh(); + inserted = true; Log.i('稍后读句子已写入会话: ${sentence.id}'); } catch (e) { - Log.w('稍后读句子写入会话失败: $e'); + // sendReadLaterSentence 内部 insert 使用 _safeDbVoid 会吞掉异常, + // 但后续 getChatMsgRecord 可能抛空指针。此处仍可能 insert 已成功。 + Log.w('稍后读句子写入会话可能部分失败: $e'); + // 即使抛异常,insert 可能已成功(_safeDbVoid 吞掉了 insert 异常), + // 仍需通知刷新,让读取方从 DB 重新加载以确认最终状态。 + inserted = true; + } + // 无论 sendReadLaterSentence 是否完全成功,只要可能已写入就通知刷新 + if (inserted) { + notifyReadlaterRefresh(); } } else { // 取消稍后读:从ChatMessage表软删除 diff --git a/lib/features/home/services/offline_manager.dart b/lib/features/home/services/offline_manager.dart index 10ffd181..e11c8862 100644 --- a/lib/features/home/services/offline_manager.dart +++ b/lib/features/home/services/offline_manager.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 离线管理器 /// 创建时间: 2026-04-28 -/// 更新时间: 2026-05-26 +/// 更新时间: 2026-06-19 /// 作用: 网络监听、离线操作队列、自动同步、智能预加载 -/// 上次更新: 实现智能预加载策略(WiFi/移动网络/低电量/用户频率) +/// 上次更新: 类型安全修复(int vs num): 浏览频率 value 使用 SafeJson.parseInt /// ============================================================ import 'dart:async'; @@ -13,6 +13,7 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:drift/drift.dart' show Value; import 'package:flutter/foundation.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../../core/storage/kv_storage.dart'; import '../../../core/storage/cache_config.dart'; @@ -270,7 +271,7 @@ class OfflineManager { final map = jsonDecode(data) as Map; _channelViewCounts.clear(); map.forEach((key, value) { - _channelViewCounts[key] = value as int; + _channelViewCounts[key] = SafeJson.parseInt(value); }); } } catch (e) { diff --git a/lib/features/member/presentation/member_page.dart b/lib/features/member/presentation/member_page.dart index 4569a172..1c9eee4f 100644 --- a/lib/features/member/presentation/member_page.dart +++ b/lib/features/member/presentation/member_page.dart @@ -1,15 +1,16 @@ /// ============================================================ /// 闲言APP — 会员页面 /// 创建时间: 2026-04-20 -/// 更新时间: 2026-06-06 +/// 更新时间: 2026-06-19 /// 作用: 会员权益展示 -/// 上次更新: 移除订阅方案和FAQ,仅保留权益展示 +/// 上次更新: 关闭按钮改用 context.appPop(),修复工作台模式下 Navigator.pop 弹空根栈导致白屏 /// ============================================================ import 'package:flutter/cupertino.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:heroine/heroine.dart'; +import '../../../core/router/app_nav_extension.dart'; import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_spacing.dart'; import '../../../core/theme/app_typography.dart'; @@ -72,7 +73,7 @@ class MemberPage extends StatelessWidget { ), CupertinoButton( padding: EdgeInsets.zero, - onPressed: () => Navigator.pop(context), + onPressed: () => context.appPop(), child: Icon( CupertinoIcons.xmark_circle_fill, size: 28, diff --git a/lib/features/note/presentation/note_edit_page.dart b/lib/features/note/presentation/note_edit_page.dart index 669d2581..a42c4c1e 100644 --- a/lib/features/note/presentation/note_edit_page.dart +++ b/lib/features/note/presentation/note_edit_page.dart @@ -1,13 +1,16 @@ /// ============================================================ /// 闲言APP — 笔记编辑页面 /// 创建时间: 2026-04-28 -/// 更新时间: 2026-06-10 +/// 更新时间: 2026-06-19 /// 作用: 新建/编辑笔记 + 自动保存 + 字数统计 + 保存时间 + Markdown预览 -/// 上次更新: 替换所有硬编码中文字符串为翻译键,支持多语言 +/// 上次更新: 桌面端接入 desktop_drop,支持拖拽图片/文本/其他文件到笔记 /// ============================================================ import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -17,6 +20,7 @@ import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_spacing.dart'; import '../../../core/theme/app_typography.dart'; import '../../../core/theme/app_radius.dart'; +import '../../../core/utils/platform/platform_utils.dart' as pu; import '../../../shared/widgets/input/app_markdown.dart'; import '../../../shared/widgets/feedback/app_toast.dart'; import '../note_model.dart'; @@ -46,6 +50,7 @@ class _NoteEditPageState extends ConsumerState bool _isPreview = false; bool _hasUnsavedChanges = false; bool _autoSaveEnabled = false; + bool _isDragging = false; // 桌面端拖拽文件状态 int _fontIndex = 0; String _noteType = 'note'; String _sourceType = ''; @@ -192,7 +197,7 @@ class _NoteEditPageState extends ConsumerState final t = ref.watch(translationsProvider); final tn = t.note; - return PopScope( + final pageContent = PopScope( onPopInvokedWithResult: (didPop, _) async { if (didPop && _hasUnsavedChanges) { await _autoSave(quiet: true); @@ -236,6 +241,75 @@ class _NoteEditPageState extends ConsumerState ), ), ); + + // 桌面端启用拖拽文件到笔记 + if (pu.isDesktop) { + return DropTarget( + onDragEntered: (_) => setState(() => _isDragging = true), + onDragExited: (_) => setState(() => _isDragging = false), + onDragDone: _handleDrop, + child: Stack( + children: [ + pageContent, + if (_isDragging) _buildDragOverlay(ext), + ], + ), + ); + } + return pageContent; + } + + /// 拖拽文件视觉反馈遮罩:半透明背景 + 虚线边框 + 提示文案 + Widget _buildDragOverlay(AppThemeExtension ext) { + return Positioned.fill( + child: IgnorePointer( + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + margin: const EdgeInsets.all(AppSpacing.sm), + decoration: BoxDecoration( + color: ext.accent.withValues(alpha: 0.08), + borderRadius: AppRadius.lgBorder, + ), + child: CustomPaint( + painter: _DashedBorderPainter( + color: ext.accent.withValues(alpha: 0.6), + radius: AppRadius.lg, + strokeWidth: 2, + dashWidth: 6, + dashGap: 4, + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.arrow_down_doc, + size: 48, + color: ext.accent, + ), + const SizedBox(height: AppSpacing.sm), + Text( + '拖放文件到笔记', + style: AppTypography.headline.copyWith( + color: ext.accent, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + '🖼️ 图片 · 📄 文本 · 📎 其他文件', + style: AppTypography.caption1.copyWith( + color: ext.accent.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + ), + ), + ), + ); } Widget _buildMarkdownToolbar(AppThemeExtension ext, TNote tn) { @@ -296,6 +370,96 @@ class _NoteEditPageState extends ConsumerState _onContentChanged(); } + // ==================== 桌面端拖拽文件处理 ==================== + + /// 图片扩展名集合 + static const _imageExts = { + '.png', '.jpg', '.jpeg', '.gif', '.webp', + }; + + /// 文本扩展名集合 + static const _textExts = {'.txt', '.md'}; + + /// 处理拖拽完成的文件列表 + Future _handleDrop(DropDoneDetails details) async { + setState(() => _isDragging = false); + final tn = ref.read(translationsProvider).note; + + for (final xFile in details.files) { + final path = xFile.path; + if (path.isEmpty) continue; + + final name = xFile.name.isNotEmpty ? xFile.name : _basename(path); + final lower = name.toLowerCase(); + final file = File(path); + + try { + if (_isImage(lower)) { + await _insertImageFile(file, name, lower); + } else if (_isText(lower)) { + await _appendTextFile(file); + } else { + _insertAtCursor('\n\n[$name](file:///$path)\n\n'); + } + } catch (e) { + AppToast.showError(tn.noteLoadFailed); + } + } + } + + /// 读取图片为 base64 并插入 Markdown 图片语法 + Future _insertImageFile(File file, String name, String lower) async { + final bytes = await file.readAsBytes(); + final base64Str = base64Encode(bytes); + final dot = lower.lastIndexOf('.'); + final rawExt = dot >= 0 ? lower.substring(dot + 1) : 'png'; + final mime = rawExt == 'jpg' ? 'jpeg' : rawExt; + _insertAtCursor('\n\n![$name](data:image/$mime;base64,$base64Str)\n\n'); + } + + /// 读取文本文件内容并追加到笔记末尾 + Future _appendTextFile(File file) async { + final content = await file.readAsString(); + final text = _contentController.text; + final sep = text.isEmpty || text.endsWith('\n') ? '' : '\n'; + _contentController.text = '$text$sep\n$content\n'; + _contentController.selection = TextSelection.collapsed( + offset: _contentController.text.length, + ); + _onContentChanged(); + } + + /// 在光标位置插入文本 + void _insertAtCursor(String insertText) { + final controller = _contentController; + final text = controller.text; + final selection = controller.selection; + final newText = text.replaceRange( + selection.start, + selection.end, + insertText, + ); + controller.text = newText; + controller.selection = TextSelection.collapsed( + offset: (selection.start + insertText.length).clamp(0, newText.length), + ); + _contentFocusNode.requestFocus(); + _onContentChanged(); + } + + bool _isImage(String lower) => + _imageExts.any(lower.endsWith); + + bool _isText(String lower) => + _textExts.any(lower.endsWith); + + /// 取路径最后一段作为文件名(兼容 / 与 \) + String _basename(String path) { + final normalized = path.replaceAll('\\', '/'); + final idx = normalized.lastIndexOf('/'); + return idx >= 0 ? normalized.substring(idx + 1) : normalized; + } + Widget _toolbarBtn( AppThemeExtension ext, IconData icon, @@ -814,3 +978,58 @@ class _NoteEditPageState extends ConsumerState } } } + +// ============================================================ +// 虚线边框绘制器 — 用于拖拽遮罩视觉反馈 +// ============================================================ + +class _DashedBorderPainter extends CustomPainter { + const _DashedBorderPainter({ + required this.color, + required this.radius, + required this.strokeWidth, + required this.dashWidth, + required this.dashGap, + }); + + final Color color; + final double radius; + final double strokeWidth; + final double dashWidth; + final double dashGap; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round; + + final rrect = RRect.fromRectAndRadius( + Rect.fromLTWH(0, 0, size.width, size.height), + Radius.circular(radius), + ); + + final path = Path()..addRRect(rrect); + final metrics = path.computeMetrics(); + + for (final metric in metrics) { + double distance = 0; + while (distance < metric.length) { + final end = (distance + dashWidth).clamp(0.0, metric.length); + canvas.drawPath(metric.extractPath(distance, end), paint); + distance += dashWidth + dashGap; + } + } + } + + @override + bool shouldRepaint(covariant _DashedBorderPainter oldDelegate) { + return oldDelegate.color != color || + oldDelegate.radius != radius || + oldDelegate.strokeWidth != strokeWidth || + oldDelegate.dashWidth != dashWidth || + oldDelegate.dashGap != dashGap; + } +} diff --git a/lib/features/onboarding/onboarding_provider.dart b/lib/features/onboarding/onboarding_provider.dart index 907ecfd3..78f385aa 100644 --- a/lib/features/onboarding/onboarding_provider.dart +++ b/lib/features/onboarding/onboarding_provider.dart @@ -30,6 +30,7 @@ class OnboardingState { this.agreementTabIndex = 0, this.soundEnabled = DefaultSettings.onboardingSoundEnabled, this.shaderBackground = false, + this.knowNewFeatures = false, }); final int currentPage; @@ -43,6 +44,7 @@ class OnboardingState { final int agreementTabIndex; final bool soundEnabled; final bool shaderBackground; + final bool knowNewFeatures; bool get canProceedAgreement => privacyAgreed && termsAgreed && permissionRead; @@ -61,6 +63,7 @@ class OnboardingState { int? agreementTabIndex, bool? soundEnabled, bool? shaderBackground, + bool? knowNewFeatures, }) { return OnboardingState( currentPage: currentPage ?? this.currentPage, @@ -74,6 +77,7 @@ class OnboardingState { agreementTabIndex: agreementTabIndex ?? this.agreementTabIndex, soundEnabled: soundEnabled ?? this.soundEnabled, shaderBackground: shaderBackground ?? this.shaderBackground, + knowNewFeatures: knowNewFeatures ?? this.knowNewFeatures, ); } } @@ -133,6 +137,11 @@ class OnboardingNotifier extends Notifier { state = state.copyWith(shaderBackground: !state.shaderBackground); } + /// 切换"了解新版本功能"开关 + void toggleKnowNewFeatures() { + state = state.copyWith(knowNewFeatures: !state.knowNewFeatures); + } + void setLocale(String locale) { state = state.copyWith(selectedLocale: locale); Log.i('Onboarding: setLocale → $locale'); @@ -154,6 +163,8 @@ class OnboardingNotifier extends Notifier { 'general_shader_background', state.shaderBackground, ); + // 保存"了解新版本功能"开关状态 + await KvStorage.setBool('know_new_features', state.knowNewFeatures); // 立即将关键数据刷入磁盘,防止App被杀后数据丢失导致重复显示引导页 try { diff --git a/lib/features/onboarding/presentation/pages/personalization_page.dart b/lib/features/onboarding/presentation/pages/personalization_page.dart index 68d5f5a9..98ce6f31 100644 --- a/lib/features/onboarding/presentation/pages/personalization_page.dart +++ b/lib/features/onboarding/presentation/pages/personalization_page.dart @@ -12,6 +12,7 @@ import 'package:confetti/confetti.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/constants/character_expression.dart'; +import '../../../../core/constants/app_constants.dart'; import '../../../../l10n/translations.dart'; import '../../../../core/router/app_nav_extension.dart'; import '../../../../core/router/app_routes.dart'; @@ -448,6 +449,17 @@ class _PersonalizationPageState extends ConsumerState { ref.read(onboardingProvider.notifier).toggleShowOnNextLaunch(); }, ), + const SizedBox(height: AppSpacing.md), + _buildSwitchRow( + ext, + label: ob.knowNewFeatures.replaceAll('{0}', AppVersion.version), + icon: CupertinoIcons.sparkles, + value: onboardingState.knowNewFeatures, + onChanged: () { + HapticService.toggleSwitch(); + ref.read(onboardingProvider.notifier).toggleKnowNewFeatures(); + }, + ), ], ), ); diff --git a/lib/features/poetry/presentation/poetry_page.dart b/lib/features/poetry/presentation/poetry_page.dart index 6891ac54..cec88a30 100644 --- a/lib/features/poetry/presentation/poetry_page.dart +++ b/lib/features/poetry/presentation/poetry_page.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 今日诗词页面 /// 创建时间: 2026-05-02 -/// 更新时间: 2026-05-21 +/// 更新时间: 2026-06-19 /// 作用: 今日诗词SDK展示 — 聊天样式交互 -/// 上次更新: 缓存优先策略,有数据时后台刷新而非白屏 +/// 上次更新: 修复输入框发送无响应(触发刷新)+收藏/分享按钮实现 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -12,13 +12,17 @@ import 'package:flutter/services.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:xianyan/core/router/app_nav_extension.dart'; +import 'package:drift/drift.dart' show Value; import '../../../core/router/app_routes.dart'; +import '../../../core/storage/database/app_database.dart'; import '../../../core/theme/app_radius.dart'; import '../../../core/theme/app_spacing.dart'; import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_typography.dart'; import '../../../core/utils/data/extensions.dart'; +import '../../../core/utils/logger.dart'; import '../../../shared/widgets/display/skeleton.dart'; +import '../../../shared/widgets/feedback/share_sheet.dart'; import '../jinrishici_models.dart'; import '../poetry_provider.dart'; import '../../../shared/widgets/adaptive/adaptive_back_button.dart'; @@ -158,19 +162,48 @@ class _PoetryPageState extends ConsumerState { _scrollToBottom(); - Future.delayed(const Duration(milliseconds: 800), () { + // 根据用户输入触发诗词刷新 + // PoetryService 暂无按关键词查询接口,这里触发刷新获取新推荐 + Future.delayed(const Duration(milliseconds: 500), () { if (!mounted) return; setState(() { _userMessages.add( - const _ChatMessage( + _ChatMessage( type: 'system', - content: '诗词之美,在于意境 🌿', - time: '', + content: '正在为你寻找与「$text」相关的诗词…', + time: DateTime.now().formatTime, ), ); }); _scrollToBottom(); }).catchError((_) {}); + + // 触发诗词刷新 + ref.read(poetryProvider.notifier).refresh().then((_) { + if (!mounted) return; + setState(() { + _userMessages.add( + _ChatMessage( + type: 'system', + content: '已为你推荐新的诗词,请查看上方卡片 🌿', + time: DateTime.now().formatTime, + ), + ); + }); + _scrollToBottom(); + }).catchError((e) { + if (!mounted) return; + setState(() { + _userMessages.add( + _ChatMessage( + type: 'system', + content: '获取诗词失败,请稍后重试', + time: DateTime.now().formatTime, + ), + ); + }); + _scrollToBottom(); + }); } void _scrollToBottom() { @@ -214,6 +247,77 @@ class _PoetryPageState extends ConsumerState { TtsPlayerSheet.show(context, text: text); } + /// 收藏当前诗词到本地数据库 + Future _favoritePoetry() async { + final poetryState = ref.read(poetryProvider); + final poetry = poetryState.poetry; + if (poetry == null) { + AppToast.showInfo('暂无诗词可收藏'); + return; + } + try { + final db = AppDatabase.instance; + final poetryId = poetry.id.isNotEmpty ? 'poetry_${poetry.id}' : 'poetry_${DateTime.now().millisecondsSinceEpoch}'; + final existing = await db.getSentencesById(poetryId); + if (existing != null) { + // 已存在,切换收藏状态 + await db.toggleFavorite(poetryId); + final isFav = !existing.isFavorite; + AppToast.showSuccess(isFav ? '已收藏' : '已取消收藏'); + } else { + // 新增并标记为收藏 + await db.insertOrUpdateSentence( + SentencesCompanion( + id: Value(poetryId), + content: Value(poetry.content.cleanHtml), + author: Value('${poetry.dynasty}·${poetry.author}'), + source: Value('今日诗词'), + tags: const Value(''), + feedType: const Value('poetry'), + feedName: const Value('今日诗词'), + feedIcon: const Value('📜'), + views: const Value(0), + imageUrl: const Value(''), + isFavorite: const Value(true), + isLiked: const Value(false), + isRead: const Value(true), + createdAt: Value(DateTime.now()), + updatedAt: Value(DateTime.now()), + ), + ); + AppToast.showSuccess('已收藏'); + } + Log.i('诗词收藏成功: ${poetry.title}'); + } catch (e) { + Log.e('诗词收藏失败', e); + AppToast.showError('收藏失败: $e'); + } + } + + /// 分享当前诗词 + void _sharePoetry() { + final poetryState = ref.read(poetryProvider); + final poetry = poetryState.poetry; + if (poetry == null) { + AppToast.showInfo('暂无诗词可分享'); + return; + } + final author = '${poetry.dynasty}·${poetry.author}'; + ShareSheet.show( + context: context, + data: ShareData( + text: poetry.content.cleanHtml, + author: author, + source: '今日诗词', + id: poetry.id.isNotEmpty ? poetry.id : null, + title: poetry.title, + tags: poetry.matchTags.isNotEmpty ? poetry.matchTags : null, + scene: ShareScene.sentence, + shareSource: 'poetry_page', + ), + ); + } + @override Widget build(BuildContext context) { final state = ref.watch(poetryProvider); @@ -437,11 +541,11 @@ class _PoetryPageState extends ConsumerState { Widget _buildQuickActions(AppThemeExtension ext) { return Row( children: [ - _buildQuickButton('🤍', '收藏', ext, () {}), + _buildQuickButton('🤍', '收藏', ext, () => _favoritePoetry()), const SizedBox(width: AppSpacing.xs), _buildQuickButton('🔊', '朗读', ext, () => _speakPoetry(ext)), const SizedBox(width: AppSpacing.xs), - _buildQuickButton('📤', '分享', ext, () {}), + _buildQuickButton('📤', '分享', ext, () => _sharePoetry()), const SizedBox(width: AppSpacing.xs), _buildQuickButton('📋', '复制', ext, () => _onQuickAction('copy')), const SizedBox(width: AppSpacing.xs), diff --git a/lib/features/poetry/presentation/poetry_settings_page.dart b/lib/features/poetry/presentation/poetry_settings_page.dart index a4816819..d18d2345 100644 --- a/lib/features/poetry/presentation/poetry_settings_page.dart +++ b/lib/features/poetry/presentation/poetry_settings_page.dart @@ -1,14 +1,15 @@ /// ============================================================ /// 闲言APP — 今日诗词设置页面 /// 创建时间: 2026-05-19 -/// 更新时间: 2026-05-19 +/// 更新时间: 2026-06-19 /// 作用: 今日诗词设置 -/// 上次更新: 改用Notifier模式替代StateProvider,修复所有编译错误 +/// 上次更新: 类型安全修复(int vs num): dailyCount 使用 SafeJson.parseInt /// ============================================================ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart' show Divider; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../../core/theme/app_radius.dart'; import '../../../core/theme/app_spacing.dart'; @@ -173,7 +174,7 @@ class PoetrySettingsPage extends ConsumerWidget { iconColor: const Color(0xFF5856D6), title: '每日推荐数量', options: const ['3', '4', '5'], - selectedKey: (settings['dailyCount'] as int).toString(), + selectedKey: SafeJson.parseInt(settings['dailyCount']).toString(), onSelected: (key) => ref .read(poetrySettingsProvider.notifier) .set('dailyCount', int.parse(key)), diff --git a/lib/features/pomodoro/pomodoro_core.dart b/lib/features/pomodoro/pomodoro_core.dart index 73014436..0fc40264 100644 --- a/lib/features/pomodoro/pomodoro_core.dart +++ b/lib/features/pomodoro/pomodoro_core.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 番茄钟核心模块(模型 + 状态管理) /// 创建时间: 2026-06-12 -/// 更新时间: 2026-06-12 +/// 更新时间: 2026-06-19 /// 作用: 合并 pomodoro_models + pomodoro_provider,统一管理番茄钟数据模型与状态 -/// 上次更新: 初始创建,由 models/pomodoro_models.dart + providers/pomodoro_provider.dart 合并 +/// 上次更新: 类型安全修复(int vs num): PomodoroRecord.fromJson 使用 SafeJson.parseInt /// ============================================================ import 'dart:async'; @@ -11,6 +11,7 @@ import 'dart:convert'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../core/storage/kv_storage.dart'; import '../../core/utils/logger.dart'; @@ -101,7 +102,7 @@ class PomodoroRecord { factory PomodoroRecord.fromJson(Map json) => PomodoroRecord( startTime: DateTime.parse(json['startTime'] as String), - durationMinutes: json['durationMinutes'] as int, + durationMinutes: SafeJson.parseInt(json['durationMinutes']), phase: PomodoroPhase.values.firstWhere( (e) => e.name == json['phase'], orElse: () => PomodoroPhase.focus, diff --git a/lib/features/profile/presentation/profile_page.dart b/lib/features/profile/presentation/profile_page.dart index b1cd250d..c6a80367 100644 --- a/lib/features/profile/presentation/profile_page.dart +++ b/lib/features/profile/presentation/profile_page.dart @@ -19,6 +19,7 @@ import '../../../core/theme/app_typography.dart'; import '../../../core/theme/app_radius.dart'; import '../../../core/router/app_routes.dart'; import '../../../core/layout/split_view_navigation_mixin.dart'; +import '../../../core/layout/workbench/right_panel_navigator.dart'; import '../../../core/services/app_exit_service.dart'; import '../../../core/services/app_store_service.dart'; import '../../../core/utils/logger.dart'; @@ -32,7 +33,6 @@ import '../../../core/utils/platform/platform_utils.dart' as pu; import '../../../shared/widgets/input/setting_row.dart'; import '../../settings/providers/theme_settings_provider.dart'; import '../../settings/providers/general_settings_provider.dart'; -import '../../settings/presentation/panels/settings_panels.dart'; import '../../auth/providers/auth_provider.dart'; import 'spotlight_search/spotlight_search_overlay.dart'; import 'spotlight_search/spotlight_shortcut.dart'; @@ -56,7 +56,6 @@ class ProfilePage extends ConsumerStatefulWidget { class _ProfilePageState extends ConsumerState with SplitViewNavigationMixin { bool _hasRefreshed = false; - bool _panelsRegistered = false; bool _searchTriggered = false; final _globalShortcutFocusNode = FocusNode(); @@ -66,7 +65,6 @@ class _ProfilePageState extends ConsumerState @override void initState() { super.initState(); - _registerPanelsOnce(); WidgetsBinding.instance.addPostFrameCallback((_) { _globalShortcutFocusNode.requestFocus(); // 如果有待处理的搜索动作(快捷方式触发),自动弹出搜索框 @@ -109,16 +107,9 @@ class _ProfilePageState extends ConsumerState } // ============================================================ - // 面板注册 & 数据刷新 + // 数据刷新 // ============================================================ - void _registerPanelsOnce() { - if (!_panelsRegistered) { - _panelsRegistered = true; - registerSettingsPanels(); - } - } - @override void didChangeDependencies() { super.didChangeDependencies(); @@ -416,6 +407,8 @@ class _ProfilePageState extends ConsumerState /// 收藏 / 历史 / 深色模式 Widget _buildQuickSettingsSection(T t) { + // 监听当前右栏路由,用于高亮选中项 + final activeRoute = ref.watch(activeRightPanelRouteProvider(2)); return SliverToBoxAdapter( child: Padding( @@ -431,6 +424,7 @@ class _ProfilePageState extends ConsumerState SettingRow( icon: CupertinoIcons.heart_fill, title: t.myFavorites, + isSelected: activeRoute == AppRoutes.favorites, onTap: () => navigateOrPanel(AppRoutes.favorites, 'favorites'), ), @@ -438,6 +432,7 @@ class _ProfilePageState extends ConsumerState SettingRow( icon: CupertinoIcons.book_fill, title: t.readingHistory, + isSelected: activeRoute == AppRoutes.history, onTap: () => navigateOrPanel(AppRoutes.history, 'history'), ), @@ -445,6 +440,7 @@ class _ProfilePageState extends ConsumerState SettingRow( icon: CupertinoIcons.moon_fill, title: t.darkMode, + isSelected: activeRoute == AppRoutes.themeSettings, trailing: const DarkModeSwitch(), onTap: () { ref.read(themeModeBounceProvider.notifier).trigger(); @@ -472,6 +468,7 @@ class _ProfilePageState extends ConsumerState /// 账号设置 / 数据管理 / 离线模式 / 缓存管理 Widget _buildAccountSection(T t) { + final activeRoute = ref.watch(activeRightPanelRouteProvider(2)); return SliverToBoxAdapter( child: Padding( @@ -487,6 +484,7 @@ class _ProfilePageState extends ConsumerState SettingRow( icon: CupertinoIcons.person_crop_circle, title: t.profile.accountSettings, + isSelected: activeRoute == AppRoutes.accountSettings, onTap: () => navigateOrPanel( AppRoutes.accountSettings, 'account_settings', @@ -496,6 +494,7 @@ class _ProfilePageState extends ConsumerState SettingRow( icon: CupertinoIcons.arrow_down_circle_fill, title: t.profile.dataManagement, + isSelected: activeRoute == AppRoutes.dataManagement, onTap: () => navigateOrPanel( AppRoutes.dataManagement, 'data_management', @@ -505,6 +504,7 @@ class _ProfilePageState extends ConsumerState SettingRow( icon: CupertinoIcons.antenna_radiowaves_left_right, title: t.profile.offlineMode, + isSelected: activeRoute == AppRoutes.offline, onTap: () => navigateOrPanel(AppRoutes.offline, 'offline_mode'), ), @@ -512,6 +512,7 @@ class _ProfilePageState extends ConsumerState SettingRow( icon: CupertinoIcons.tray_full_fill, title: t.cacheManagement, + isSelected: activeRoute == AppRoutes.cacheManagement, onTap: () => navigateOrPanel( AppRoutes.cacheManagement, 'cache_management', @@ -535,6 +536,7 @@ class _ProfilePageState extends ConsumerState /// 语言 / 主题定制 / 通用设置 / 桌面小组件 / 句子来源 Widget _buildGeneralSection(T t, GeneralSettingsState settings) { + final activeRoute = ref.watch(activeRightPanelRouteProvider(2)); return SliverToBoxAdapter( child: Padding( @@ -549,6 +551,7 @@ class _ProfilePageState extends ConsumerState SettingRow( icon: CupertinoIcons.globe, title: t.language, + isSelected: activeRoute == AppRoutes.languageSettings, trailing: Text( AppLocale.fromId(settings.languageId).nativeName, style: AppTypography.subhead.copyWith( @@ -567,6 +570,7 @@ class _ProfilePageState extends ConsumerState SettingRow( icon: CupertinoIcons.paintbrush_fill, title: t.themeCustomization, + isSelected: activeRoute == AppRoutes.themeSettings, onTap: () => navigateOrPanel( AppRoutes.themeSettings, 'theme_settings', @@ -576,6 +580,7 @@ class _ProfilePageState extends ConsumerState SettingRow( icon: CupertinoIcons.gear_solid, title: t.generalSettings, + isSelected: activeRoute == AppRoutes.generalSettings, onTap: () => navigateOrPanel( AppRoutes.generalSettings, 'general_settings', @@ -585,6 +590,7 @@ class _ProfilePageState extends ConsumerState SettingRow( icon: CupertinoIcons.square_grid_2x2, title: t.desktopWidgets, + isSelected: activeRoute == AppRoutes.widgetManagement, onTap: () => navigateOrPanel( AppRoutes.widgetManagement, 'widget_management', @@ -594,6 +600,7 @@ class _ProfilePageState extends ConsumerState SettingRow( icon: CupertinoIcons.book, title: t.sentenceSource, + isSelected: activeRoute == AppRoutes.source, onTap: () => navigateOrPanel(AppRoutes.source, 'source'), ), @@ -615,6 +622,7 @@ class _ProfilePageState extends ConsumerState /// 关于 / 评分 / 实验性功能 Widget _buildAboutSection(T t) { + final activeRoute = ref.watch(activeRightPanelRouteProvider(2)); return SliverToBoxAdapter( child: Padding( @@ -629,6 +637,7 @@ class _ProfilePageState extends ConsumerState SettingRow( icon: CupertinoIcons.info_circle, title: t.aboutApp, + isSelected: activeRoute == AppRoutes.about, onTap: () => navigateOrPanel(AppRoutes.about, 'about'), ), const SettingsDivider(), @@ -643,6 +652,8 @@ class _ProfilePageState extends ConsumerState SettingRow( icon: CupertinoIcons.lab_flask_solid, title: t.profile.experimentalFeature, + isSelected: + activeRoute == AppRoutes.experimentalFeatures, onTap: () => navigateOrPanel( AppRoutes.experimentalFeatures, 'experimental_features', diff --git a/lib/features/profile/presentation/spotlight_search/spotlight_search_provider.dart b/lib/features/profile/presentation/spotlight_search/spotlight_search_provider.dart index f0fcb2cc..2ba83e50 100644 --- a/lib/features/profile/presentation/spotlight_search/spotlight_search_provider.dart +++ b/lib/features/profile/presentation/spotlight_search/spotlight_search_provider.dart @@ -1,14 +1,15 @@ /// ============================================================ /// 闲言APP — Spotlight搜索状态管理 /// 创建时间: 2026-06-07 -/// 更新时间: 2026-06-07 +/// 更新时间: 2026-06-19 /// 作用: 管理Spotlight搜索的查询、结果、选中索引、最近搜索等状态 -/// 上次更新: 新增最近访问记录排序和搜索建议功能 +/// 上次更新: 类型安全修复(int vs num): _loadRecentVisits 使用 SafeJson.parseInt /// ============================================================ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../../../core/storage/kv_storage.dart'; import '../../../../core/utils/logger.dart'; @@ -288,7 +289,7 @@ class SpotlightSearchNotifier extends Notifier { final raw = KvStorage.getString(_kRecentVisits); if (raw != null && raw.isNotEmpty) { final decoded = jsonDecode(raw) as Map; - return decoded.map((k, v) => MapEntry(k, v as int)); + return decoded.map((k, v) => MapEntry(k, SafeJson.parseInt(v))); } } catch (e) { Log.w('Spotlight搜索: 读取最近访问记录失败 $e'); diff --git a/lib/features/reading_report/reading_report_core.dart b/lib/features/reading_report/reading_report_core.dart index 72651ad3..1408e524 100644 --- a/lib/features/reading_report/reading_report_core.dart +++ b/lib/features/reading_report/reading_report_core.dart @@ -149,12 +149,33 @@ class TrendPoint { final int coins; final int favorites; + /// 从JSON解析趋势数据点 + /// + /// 兼容多种字段名: + /// - date: date / day / time + /// - views: views / view_count / total_views / reads / read_count + /// - signins: signins / signin_count / total_signins / sign_in_count + /// - coins: coins / coin_count / total_coins + /// - favorites: favorites / favorite_count / total_favorites factory TrendPoint.fromJson(Map json) => TrendPoint( - date: SafeJson.parseString(json['date']), - views: SafeJson.parseInt(json['views']), - signins: SafeJson.parseInt(json['signins']), - coins: SafeJson.parseInt(json['coins']), - favorites: SafeJson.parseInt(json['favorites']), + date: SafeJson.parseString( + json['date'] ?? json['day'] ?? json['time'] ?? '', + ), + views: SafeJson.parseInt( + json['views'] ?? json['view_count'] ?? json['total_views'] ?? + json['reads'] ?? json['read_count'] ?? 0, + ), + signins: SafeJson.parseInt( + json['signins'] ?? json['signin_count'] ?? json['total_signins'] ?? + json['sign_in_count'] ?? 0, + ), + coins: SafeJson.parseInt( + json['coins'] ?? json['coin_count'] ?? json['total_coins'] ?? 0, + ), + favorites: SafeJson.parseInt( + json['favorites'] ?? json['favorite_count'] ?? json['total_favorites'] ?? + json['favs'] ?? 0, + ), ); } @@ -166,11 +187,35 @@ class HeatmapDay { final int count; final int level; - factory HeatmapDay.fromJson(Map json) => HeatmapDay( - date: SafeJson.parseString(json['date']), - count: SafeJson.parseInt(json['count']), - level: SafeJson.parseInt(json['level']), - ); + /// 从JSON解析热力图数据点 + /// + /// 兼容多种字段名: + /// - date: date / day / time + /// - count: count / views / value / activity_count + /// - level: level / intensity (可选,未提供时根据count自动计算) + factory HeatmapDay.fromJson(Map json) { + final date = SafeJson.parseString( + json['date'] ?? json['day'] ?? json['time'] ?? '', + ); + final count = SafeJson.parseInt( + json['count'] ?? json['views'] ?? json['value'] ?? + json['activity_count'] ?? 0, + ); + final level = SafeJson.parseInt(json['level'] ?? json['intensity'] ?? 0); + // 若level未提供,根据count自动计算(0-4级) + final computedLevel = level > 0 + ? level + : count == 0 + ? 0 + : count < 5 + ? 1 + : count < 15 + ? 2 + : count < 30 + ? 3 + : 4; + return HeatmapDay(date: date, count: count, level: computedLevel); + } } /// 成就解锁记录 @@ -240,7 +285,9 @@ class ReadingReportService { } try { - final raw = await UserCenterService.getHeatmap(); + // API要求传year参数,未传时服务端可能返回空数据 + final currentYear = DateTime.now().year.toString(); + final raw = await UserCenterService.getHeatmap(year: currentYear); heatmapData = _safeCastMap(raw); } on ApiException catch (e) { Log.e('使用报告: heatmap加载失败', e); @@ -391,42 +438,68 @@ class ReadingReportService { /// 安全类型转换: dynamic → Map? /// 防止API返回List时 as Map? 强转崩溃 + /// + /// 若数据为List,包装为 {'list': [...]} 保留完整数据(而非截断为首元素), + /// 后续解析方法(_parseTrend/_parseHeatmap)会识别 'list' 键。 static Map? _safeCastMap(dynamic data) { + if (data == null) return null; if (data is Map) return data; if (data is Map) return Map.from(data); - if (data is List && data.isNotEmpty) { - final first = data.first; - if (first is Map) return first; - if (first is Map) return Map.from(first); + if (data is List) { + if (data.isEmpty) return null; + // 包装为 {'list': [...]} 保留完整数据 + return {'list': data}; } return null; } static List _parseTrend(Map data) { - final rawPoints = data['points'] as List?; - if (rawPoints == null) { - final overview = _safeCastMap(data['overview']); - if (overview != null) { - final dateStr = _todayStr(); - return [ - TrendPoint( - date: dateStr, - views: _parseInt(overview['view_count']), - signins: _parseInt(overview['signin_count']), - coins: _parseInt(overview['coin_count']), - favorites: _parseInt(overview['favorite_count']), - ), - ]; - } - return []; + // 兼容多种响应格式: + // 1. {points: [...]} - 标准格式 + // 2. {trend: [...]} - trend键 + // 3. {list: [...]} - list键 + // 4. {data: [...]} - data键 + // 5. [...] - 直接是数组(被_safeCastMap截断后不会到这里) + // 6. {overview: {...}} - overview对象,转为单点 + List? rawPoints = data['points'] as List?; + rawPoints ??= data['trend'] as List?; + rawPoints ??= data['list'] as List?; + rawPoints ??= data['data'] as List?; + + if (rawPoints != null) { + return rawPoints + .map((e) => TrendPoint.fromJson(e as Map)) + .toList(); } - return rawPoints - .map((e) => TrendPoint.fromJson(e as Map)) - .toList(); + + // 尝试 overview 对象,转为单点趋势 + final overview = _safeCastMap(data['overview']) ?? _safeCastMap(data); + if (overview != null) { + final dateStr = _todayStr(); + return [ + TrendPoint( + date: dateStr, + views: _parseInt(overview['total_views'] ?? overview['view_count']), + signins: _parseInt( + overview['total_signins'] ?? overview['signin_count'], + ), + coins: _parseInt(overview['total_coins'] ?? overview['coin_count']), + favorites: _parseInt( + overview['total_favorites'] ?? overview['favorite_count'], + ), + ), + ]; + } + return []; } static List _parseHeatmap(Map data) { - final days = data['days'] as List?; + // API文档: 响应字段 heatmap (array [{date, count}]) + // 兼容多种键名: heatmap / days / data / list + List? days = data['heatmap'] as List?; + days ??= data['days'] as List?; + days ??= data['data'] as List?; + days ??= data['list'] as List?; if (days == null) return []; return days .map((e) => HeatmapDay.fromJson(e as Map)) diff --git a/lib/features/search/presentation/search_page.dart b/lib/features/search/presentation/search_page.dart index 63d5cdc3..f8cc8c2e 100644 --- a/lib/features/search/presentation/search_page.dart +++ b/lib/features/search/presentation/search_page.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 搜索页面 /// 创建时间: 2026-04-20 -/// 更新时间: 2026-06-13 +/// 更新时间: 2026-06-19 /// 作用: 综合搜索 — SearchAll API + 搜索建议 + 热门搜索 + 统一FeedItem -/// 上次更新: 增加触觉反馈、搜索栏GlassContainer毛玻璃效果、入场slideY动画 +/// 上次更新: 取消按钮改用 context.appPop(),修复工作台模式下 Navigator.pop 弹空根栈导致白屏 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -258,7 +258,7 @@ class _SearchPageState extends ConsumerState { padding: EdgeInsets.zero, onPressed: () { HapticService.light(); - Navigator.pop(context); + context.appPop(); }, child: Text( t.common.cancel, diff --git a/lib/features/settings/presentation/account/security_question_page.dart b/lib/features/settings/presentation/account/security_question_page.dart index 1732f061..577de1f2 100644 --- a/lib/features/settings/presentation/account/security_question_page.dart +++ b/lib/features/settings/presentation/account/security_question_page.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 密保问题管理页面 /// 创建时间: 2026-05-15 -/// 更新时间: 2026-05-31 +/// 更新时间: 2026-06-19 /// 作用: 设置/修改密保问题,支持多验证方式(含邮箱验证码) -/// 上次更新: 修复设置密保后refreshUser未await导致状态未及时更新 +/// 上次更新: 修复设置密保后refreshUser解析失败导致状态未更新——增强错误反馈 /// ============================================================ import 'dart:async'; @@ -698,10 +698,17 @@ class _SecurityQuestionPageState extends ConsumerState { userId: userId, ); if (!mounted) return; + // 服务端已设置成功,先显示成功提示 AppToast.showSuccess( _hasSecQuestion ? '密保问题修改成功' : '密保问题设置成功', ); - await ref.read(authProvider.notifier).refreshUser(); + // 刷新用户信息(本地状态同步),失败时仅警告不影响主流程 + try { + await ref.read(authProvider.notifier).refreshUser(); + } catch (e) { + // refreshUser 内部已 catch,这里兜底防止极端情况 + AppToast.showWarning('密保已设置,刷新用户信息失败,请稍后重试'); + } if (!mounted) return; if (context.canPop()) context.pop(); } catch (e) { diff --git a/lib/features/settings/presentation/experimental_features_page.dart b/lib/features/settings/presentation/experimental_features_page.dart index ed580d45..7ded8660 100644 --- a/lib/features/settings/presentation/experimental_features_page.dart +++ b/lib/features/settings/presentation/experimental_features_page.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — Beta 功能页面 /// 创建时间: 2026-05-30 -/// 更新时间: 2026-06-17 +/// 更新时间: 2026-06-19 /// 作用: 展示开发中/测试中/预览中的功能列表和问题列表,接入远程FeatureFlag服务 -/// 上次更新: 修复问卷提交后按钮隐藏只在iOS端生效的问题——Sheet返回值即时更新UI + await修复时序 +/// 上次更新: 填写问卷按钮增加右上角可关闭小角标,关闭后本次启动不再显示 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -39,6 +39,8 @@ class _ExperimentalFeaturesPageState String _issueFilter = 'all'; late final PageController _pageController; bool _questionnaireSubmitted = false; + // 问卷按钮是否被用户主动关闭(仅本次启动有效,重启后恢复) + bool _questionnaireDismissed = false; @override void initState() { @@ -106,8 +108,8 @@ class _ExperimentalFeaturesPageState children: [_buildFeaturesTab(ext, t), _buildIssuesTab(ext, t)], ), ), - // 底部问卷按钮(提交后隐藏) - if (!_questionnaireSubmitted) + // 底部问卷按钮(提交后或被用户关闭后隐藏) + if (!_questionnaireSubmitted && !_questionnaireDismissed) Padding( padding: const EdgeInsets.fromLTRB( AppSpacing.md, @@ -117,29 +119,40 @@ class _ExperimentalFeaturesPageState ), child: SizedBox( width: double.infinity, - child: CupertinoButton( - color: ext.accent, - borderRadius: BorderRadius.circular(10), - padding: const EdgeInsets.symmetric(vertical: 14), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - CupertinoIcons.question_circle_fill, - size: 18, - color: ext.textOnAccent, + child: Stack( + clipBehavior: Clip.none, + children: [ + CupertinoButton( + color: ext.accent, + borderRadius: BorderRadius.circular(10), + padding: const EdgeInsets.symmetric(vertical: 14), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.question_circle_fill, + size: 18, + color: ext.textOnAccent, + ), + const SizedBox(width: 6), + Text( + t.beta.questionnaireBtn, + style: AppTypography.subhead.copyWith( + color: ext.textOnAccent, + fontWeight: FontWeight.w600, + ), + ), + ], ), - const SizedBox(width: 6), - Text( - t.beta.questionnaireBtn, - style: AppTypography.subhead.copyWith( - color: ext.textOnAccent, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - onPressed: () => _showQuestionnaire(ext, t), + onPressed: () => _showQuestionnaire(ext, t), + ), + // 右上角可关闭小角标 + Positioned( + top: -6, + right: -6, + child: _buildDismissBadge(ext), + ), + ], ), ), ), @@ -151,6 +164,46 @@ class _ExperimentalFeaturesPageState // ---- 问卷 ---- + /// 构建右上角可关闭小角标 + /// + /// 设计:圆形红色徽标 + xmark,点击后本次启动隐藏问卷按钮 + Widget _buildDismissBadge(AppThemeExtension ext) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + setState(() => _questionnaireDismissed = true); + AppToast.showInfo('已暂时隐藏问卷入口,重启后恢复'); + }, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: CupertinoColors.systemRed.resolveFrom(context), + shape: BoxShape.circle, + border: Border.all( + color: ext.bgPrimary, + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: CupertinoColors.systemRed.resolveFrom(context) + .withValues(alpha: 0.4), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + child: const Center( + child: Icon( + CupertinoIcons.xmark, + size: 10, + color: CupertinoColors.white, + ), + ), + ), + ); + } + /// 弹出问卷Sheet /// /// Sheet 关闭后返回是否已提交: diff --git a/lib/features/settings/presentation/general/general_settings_page.dart b/lib/features/settings/presentation/general/general_settings_page.dart index e8f0002d..82ee91c5 100644 --- a/lib/features/settings/presentation/general/general_settings_page.dart +++ b/lib/features/settings/presentation/general/general_settings_page.dart @@ -149,6 +149,7 @@ class _GeneralSettingsPageState extends ConsumerState navBarPositionIndex: splitState.navBarPosition.index, splitRatio: splitState.splitRatio, splitViewEnabled: splitState.splitViewEnabled, + workbenchEnabled: splitState.workbenchEnabled, ); return filterSettingSections( allSections, @@ -942,6 +943,8 @@ class _GeneralSettingsPageState extends ConsumerState notifier.setNearbyDiscovery(value); case 'split_view_enabled': ref.read(splitViewProvider.notifier).setSplitViewEnabled(value); + case 'workbench_enabled': + ref.read(splitViewProvider.notifier).setWorkbenchEnabled(value); } } @@ -1002,6 +1005,8 @@ class _GeneralSettingsPageState extends ConsumerState context.appPush(AppRoutes.fontManagement); case 'app_lock': context.appPush(AppRoutes.appLockSettings); + case 'workbench_settings': + context.appPush(AppRoutes.workbenchSettings); } } diff --git a/lib/features/settings/presentation/general/general_settings_sections.dart b/lib/features/settings/presentation/general/general_settings_sections.dart index bb44c6d9..1b508912 100644 --- a/lib/features/settings/presentation/general/general_settings_sections.dart +++ b/lib/features/settings/presentation/general/general_settings_sections.dart @@ -10,7 +10,6 @@ import 'package:flutter/cupertino.dart'; import '../../../../l10n/app_locale.dart'; import '../../../../l10n/translations.dart'; import '../../../../core/theme/app_colors.dart'; -import '../../../../core/providers/split_view_provider.dart'; import '../../providers/general_settings_provider.dart'; import '../../providers/plugin_provider.dart'; import 'setting_models.dart'; @@ -26,6 +25,7 @@ List buildGeneralSettingSections({ required int navBarPositionIndex, required double splitRatio, required bool splitViewEnabled, + required bool workbenchEnabled, }) { return [ // ── 交互设置 ── @@ -235,38 +235,17 @@ List buildGeneralSettingSections({ type: SettingType.toggle, value: settings.immersiveStatusBar, ), + // 工作台模式相关设置已迁移至独立的工作台设置页 SettingItem( - id: 'nav_bar_position', - icon: CupertinoIcons.sidebar_left, - iconColor: AppColors.iosBlue, - title: t.settings.display.navBarPosition, - subtitle: t.settings.display.navBarPositionSubtitle, - type: SettingType.selection, - displayValue: - (navBarPositionIndex >= 0 && - navBarPositionIndex < NavBarPosition.values.length) - ? NavBarPosition.values[navBarPositionIndex].label - : t.settings.display.navBarPositionLeft, - ), - SettingItem( - id: 'split_view_ratio', - icon: CupertinoIcons.rectangle_split_3x1, - iconColor: AppColors.iosPurple, - title: t.settings.display.splitViewRatio, - subtitle: t.settings.display.splitViewRatioSubtitle, - type: SettingType.selection, + id: 'workbench_settings', + icon: CupertinoIcons.macwindow, + iconColor: AppColors.iosIndigo, + title: t.settings.display.workbenchEnabled, + subtitle: t.settings.display.workbenchEnabledSubtitle, + type: SettingType.navigation, displayValue: '${(splitRatio * 100).toInt()}:${(100 - splitRatio * 100).toInt()}', ), - SettingItem( - id: 'split_view_enabled', - icon: CupertinoIcons.rectangle_split_3x1, - iconColor: AppColors.iosGreen, - title: t.settings.display.splitViewEnabled, - subtitle: t.settings.display.splitViewEnabledSubtitle, - type: SettingType.toggle, - value: splitViewEnabled, - ), SettingItem( id: 'content_density', icon: CupertinoIcons.rectangle_3_offgrid, diff --git a/lib/features/settings/presentation/image_cache_models.dart b/lib/features/settings/presentation/image_cache_models.dart index e9c14fb3..e3d64009 100644 --- a/lib/features/settings/presentation/image_cache_models.dart +++ b/lib/features/settings/presentation/image_cache_models.dart @@ -1,12 +1,13 @@ /// ============================================================ /// 闲言APP — 图片缓存模型与工具类 /// 创建时间: 2026-05-30 -/// 更新时间: 2026-05-30 +/// 更新时间: 2026-06-19 /// 作用: 图片缓存页面的数据模型、枚举、格式化工具、分类映射 -/// 上次更新: 新增ImageCacheState/CacheCleanLogEntry/CacheImageExtensions,扩展CacheItem字段 +/// 上次更新: 类型安全修复(int vs num): CacheCleanLogEntry.fromJson 使用 SafeJson.parseInt /// ============================================================ import 'package:flutter/cupertino.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../../core/services/data/image_cache_metadata_service.dart'; import '../../../l10n/translations.dart'; @@ -111,8 +112,8 @@ class CacheCleanLogEntry { return CacheCleanLogEntry( timestamp: DateTime.parse(json['timestamp'] as String), type: json['type'] as String, - count: json['count'] as int, - size: json['size'] as int, + count: SafeJson.parseInt(json['count']), + size: SafeJson.parseInt(json['size']), category: json['category'] as String?, ); } diff --git a/lib/features/settings/presentation/panels/settings_panels.dart b/lib/features/settings/presentation/panels/settings_panels.dart deleted file mode 100644 index 6548f79b..00000000 --- a/lib/features/settings/presentation/panels/settings_panels.dart +++ /dev/null @@ -1,872 +0,0 @@ -/// ============================================================ -/// 闲言APP — 设置面板组件集合(宽屏右侧面板) -/// 创建时间: 2026-05-29 -/// 更新时间: 2026-05-29 -/// 作用: 宽屏分屏时右侧显示各设置项内容,包含面板包装器和面板注册 -/// 上次更新: 初始创建 -/// ============================================================ - -import 'package:flutter/cupertino.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../../../core/theme/app_theme.dart'; -import '../../../../core/theme/app_spacing.dart'; -import '../../../../core/theme/app_typography.dart'; -import '../../../../core/theme/app_radius.dart'; -import '../../../../core/providers/split_view_provider.dart'; -import '../../../../core/layout/right_panel_registry.dart'; -import '../../../../shared/widgets/containers/glass_container.dart'; -import '../../../../shared/widgets/feedback/app_toast.dart'; -import '../../../home/services/cache_service.dart'; - -/// 设置面板包装器 — 统一面板头部(标题 + 关闭按钮) -class SettingsPanelWrapper extends ConsumerWidget { - const SettingsPanelWrapper({ - required this.title, - required this.icon, - required this.child, - super.key, - }); - - final String title; - final IconData icon; - final Widget child; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final ext = AppTheme.ext(context); - - return Container( - color: ext.bgPrimary, - child: Column( - children: [ - _buildPanelHeader(context, ext, ref), - Expanded(child: child), - ], - ), - ); - } - - Widget _buildPanelHeader( - BuildContext context, - AppThemeExtension ext, - WidgetRef ref, - ) { - return Container( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.sm, - AppSpacing.md, - AppSpacing.sm, - ), - decoration: BoxDecoration( - color: ext.bgCard, - border: Border( - bottom: BorderSide( - color: ext.textHint.withValues(alpha: 0.08), - width: 0.5, - ), - ), - ), - child: Row( - children: [ - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: ext.accent.withValues(alpha: 0.12), - borderRadius: AppRadius.smBorder, - ), - child: Icon(icon, size: 16, color: ext.accent), - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - title, - style: AppTypography.headline.copyWith( - color: ext.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - ), - CupertinoButton( - padding: EdgeInsets.zero, - minimumSize: Size.zero, - onPressed: () { - ref.read(splitViewProvider.notifier).setProfileRightPanel(null); - }, - child: Icon( - CupertinoIcons.xmark_circle_fill, - color: ext.textHint, - size: 24, - ), - ), - ], - ), - ); - } -} - -/// 账号设置面板 -class AccountSettingsPanel extends ConsumerWidget { - const AccountSettingsPanel({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return SettingsPanelWrapper( - title: '账号设置', - icon: CupertinoIcons.person_crop_circle, - child: _buildPlaceholderContent(context, '账号与安全设置', [ - ('👤', '个人信息', '修改头像、昵称、签名'), - ('🔒', '密码管理', '修改登录密码'), - ('📱', '手机绑定', '绑定/换绑手机号'), - ('📧', '邮箱绑定', '绑定/换绑邮箱'), - ('🛡️', '登录设备', '管理已登录设备'), - ('🚪', '退出登录', '退出当前账号'), - ]), - ); - } - - Widget _buildPlaceholderContent( - BuildContext context, - String subtitle, - List<(String, String, String)> items, - ) { - final ext = AppTheme.ext(context); - return SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - subtitle, - style: AppTypography.subhead.copyWith(color: ext.textSecondary), - ), - const SizedBox(height: AppSpacing.md), - GlassContainer( - depth: GlassDepth.elevated, - padding: EdgeInsets.zero, - child: Column( - children: items.map((item) { - return CupertinoButton( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - onPressed: null, - child: Row( - children: [ - Text(item.$1, style: const TextStyle(fontSize: 18)), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.$2, - style: AppTypography.body.copyWith( - color: ext.textPrimary, - ), - ), - Text( - item.$3, - style: AppTypography.caption1.copyWith( - color: ext.textHint, - ), - ), - ], - ), - ), - Icon( - CupertinoIcons.chevron_right, - size: 14, - color: ext.textHint, - ), - ], - ), - ); - }).toList(), - ), - ), - ], - ), - ); - } -} - -/// 数据管理面板 -class DataManagementPanel extends ConsumerWidget { - const DataManagementPanel({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final ext = AppTheme.ext(context); - return SettingsPanelWrapper( - title: '数据管理', - icon: CupertinoIcons.arrow_down_circle_fill, - child: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '管理您的数据备份与导出', - style: AppTypography.subhead.copyWith(color: ext.textSecondary), - ), - const SizedBox(height: AppSpacing.md), - GlassContainer( - depth: GlassDepth.elevated, - padding: EdgeInsets.zero, - child: Column( - children: [ - _buildSettingItem(ext, '☁️', '云端备份', '自动同步数据到云端'), - _buildSettingItem(ext, '📤', '数据导出', '导出个人数据'), - _buildSettingItem(ext, '📥', '数据导入', '从备份恢复数据'), - _buildSettingItem(ext, '🗑️', '清除数据', '清除本地缓存数据'), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildSettingItem( - AppThemeExtension ext, - String emoji, - String title, - String subtitle, - ) { - return CupertinoButton( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - onPressed: null, - child: Row( - children: [ - Text(emoji, style: const TextStyle(fontSize: 18)), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: AppTypography.body.copyWith(color: ext.textPrimary), - ), - Text( - subtitle, - style: AppTypography.caption1.copyWith(color: ext.textHint), - ), - ], - ), - ), - Icon(CupertinoIcons.chevron_right, size: 14, color: ext.textHint), - ], - ), - ); - } -} - -/// 离线模式面板 -class OfflineModePanel extends ConsumerWidget { - const OfflineModePanel({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final ext = AppTheme.ext(context); - return SettingsPanelWrapper( - title: '离线模式', - icon: CupertinoIcons.antenna_radiowaves_left_right, - child: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '离线模式下可继续阅读已缓存内容', - style: AppTypography.subhead.copyWith(color: ext.textSecondary), - ), - const SizedBox(height: AppSpacing.md), - GlassContainer( - depth: GlassDepth.elevated, - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - children: [ - const Text('📡', style: TextStyle(fontSize: 48)), - const SizedBox(height: AppSpacing.sm), - Text( - '离线模式设置', - style: AppTypography.headline.copyWith( - color: ext.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: AppSpacing.xs), - Text( - '选择需要离线缓存的内容类型', - style: AppTypography.caption1.copyWith(color: ext.textHint), - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -/// 缓存管理面板 -class CacheManagementPanel extends ConsumerStatefulWidget { - const CacheManagementPanel({super.key}); - - @override - ConsumerState createState() => - _CacheManagementPanelState(); -} - -class _CacheManagementPanelState extends ConsumerState { - bool _isClearing = false; - - /// 清除所有缓存 - Future _clearAllCache() async { - final confirmed = await showCupertinoDialog( - context: context, - builder: (ctx) => CupertinoAlertDialog( - title: const Text('清除缓存'), - content: const Text('确定清除所有缓存数据吗?离线缓存内容将被删除。'), - actions: [ - CupertinoDialogAction( - child: const Text('取消'), - onPressed: () => Navigator.pop(ctx, false), - ), - CupertinoDialogAction( - isDestructiveAction: true, - child: const Text('清除'), - onPressed: () => Navigator.pop(ctx, true), - ), - ], - ), - ); - if (confirmed != true) return; - - setState(() => _isClearing = true); - try { - await CacheService.clearAllCache(); - if (mounted) { - AppToast.showSuccess('缓存已清除 ✨'); - } - } catch (e) { - if (mounted) { - AppToast.showError('清除缓存失败'); - } - } finally { - if (mounted) { - setState(() => _isClearing = false); - } - } - } - - @override - Widget build(BuildContext context) { - final ext = AppTheme.ext(context); - return SettingsPanelWrapper( - title: '缓存管理', - icon: CupertinoIcons.tray_full_fill, - child: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '查看和管理应用缓存', - style: AppTypography.subhead.copyWith(color: ext.textSecondary), - ), - const SizedBox(height: AppSpacing.md), - GlassContainer( - depth: GlassDepth.elevated, - padding: const EdgeInsets.all(AppSpacing.md), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _buildCacheStat(ext, '🖼️', '图片', '12.3 MB'), - _buildCacheStat(ext, '📝', '文本', '2.1 MB'), - _buildCacheStat(ext, '🎵', '音频', '45.6 MB'), - ], - ), - ), - const SizedBox(height: AppSpacing.md), - SizedBox( - width: double.infinity, - child: CupertinoButton( - color: ext.accent, - borderRadius: AppRadius.mdBorder, - onPressed: _isClearing ? null : _clearAllCache, - child: _isClearing - ? const CupertinoActivityIndicator(color: CupertinoColors.white) - : const Text('清除所有缓存'), - ), - ), - ], - ), - ), - ); - } - - Widget _buildCacheStat( - AppThemeExtension ext, - String emoji, - String label, - String size, - ) { - return Column( - children: [ - Text(emoji, style: const TextStyle(fontSize: 24)), - const SizedBox(height: 4), - Text( - size, - style: AppTypography.subhead.copyWith( - color: ext.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - Text( - label, - style: AppTypography.caption2.copyWith(color: ext.textHint), - ), - ], - ); - } -} - -/// 语言设置面板 -class LanguageSettingsPanel extends ConsumerWidget { - const LanguageSettingsPanel({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final ext = AppTheme.ext(context); - return SettingsPanelWrapper( - title: '语言设置', - icon: CupertinoIcons.globe, - child: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '选择应用显示语言', - style: AppTypography.subhead.copyWith(color: ext.textSecondary), - ), - const SizedBox(height: AppSpacing.md), - GlassContainer( - depth: GlassDepth.elevated, - padding: EdgeInsets.zero, - child: Column( - children: [ - _buildLanguageOption(ext, '🇨🇳', '简体中文', true), - _buildLanguageOption(ext, '🇺🇸', 'English', false), - _buildLanguageOption(ext, '🇯🇵', '日本語', false), - _buildLanguageOption(ext, '🇰🇷', '한국어', false), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildLanguageOption( - AppThemeExtension ext, - String flag, - String name, - bool isSelected, - ) { - return CupertinoButton( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - onPressed: null, - child: Row( - children: [ - Text(flag, style: const TextStyle(fontSize: 20)), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - name, - style: AppTypography.body.copyWith(color: ext.textPrimary), - ), - ), - if (isSelected) - Icon( - CupertinoIcons.checkmark_circle_fill, - size: 20, - color: ext.accent, - ), - ], - ), - ); - } -} - -/// 主题定制面板 -class ThemeSettingsPanel extends ConsumerWidget { - const ThemeSettingsPanel({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final ext = AppTheme.ext(context); - return SettingsPanelWrapper( - title: '主题定制', - icon: CupertinoIcons.paintbrush_fill, - child: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '个性化你的应用外观', - style: AppTypography.subhead.copyWith(color: ext.textSecondary), - ), - const SizedBox(height: AppSpacing.md), - GlassContainer( - depth: GlassDepth.elevated, - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _buildThemePreview(ext, '☀️', '浅色', true), - _buildThemePreview(ext, '🌙', '深色', false), - _buildThemePreview(ext, '🔄', '跟随系统', false), - ], - ), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildThemePreview( - AppThemeExtension ext, - String emoji, - String label, - bool isSelected, - ) { - return Column( - children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: ext.bgSecondary, - borderRadius: AppRadius.lgBorder, - border: isSelected ? Border.all(color: ext.accent, width: 2) : null, - ), - child: Center( - child: Text(emoji, style: const TextStyle(fontSize: 28)), - ), - ), - const SizedBox(height: 4), - Text( - label, - style: AppTypography.caption2.copyWith( - color: isSelected ? ext.accent : ext.textSecondary, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - ), - ), - ], - ); - } -} - -/// 通用设置面板 -class GeneralSettingsPanel extends ConsumerWidget { - const GeneralSettingsPanel({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final ext = AppTheme.ext(context); - return SettingsPanelWrapper( - title: '通用设置', - icon: CupertinoIcons.gear_solid, - child: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '应用通用行为设置', - style: AppTypography.subhead.copyWith(color: ext.textSecondary), - ), - const SizedBox(height: AppSpacing.md), - GlassContainer( - depth: GlassDepth.elevated, - padding: EdgeInsets.zero, - child: Column( - children: [ - _buildToggleItem(ext, '🔔', '消息通知', true), - _buildToggleItem(ext, '🔊', '声音效果', true), - _buildToggleItem(ext, '📳', '震动反馈', false), - _buildToggleItem(ext, '🔄', '自动更新', true), - _buildToggleItem(ext, '📊', '使用统计', false), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildToggleItem( - AppThemeExtension ext, - String emoji, - String title, - bool value, - ) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - child: Row( - children: [ - Text(emoji, style: const TextStyle(fontSize: 18)), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - title, - style: AppTypography.body.copyWith(color: ext.textPrimary), - ), - ), - CupertinoSwitch( - value: value, - activeTrackColor: ext.accent, - onChanged: null, - ), - ], - ), - ); - } -} - -/// 桌面小组件面板 -class WidgetManagementPanel extends ConsumerWidget { - const WidgetManagementPanel({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final ext = AppTheme.ext(context); - return SettingsPanelWrapper( - title: '桌面小组件', - icon: CupertinoIcons.square_grid_2x2, - child: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '管理桌面小组件', - style: AppTypography.subhead.copyWith(color: ext.textSecondary), - ), - const SizedBox(height: AppSpacing.md), - GlassContainer( - depth: GlassDepth.elevated, - padding: const EdgeInsets.all(AppSpacing.md), - child: Center( - child: Column( - children: [ - const Text('🧩', style: TextStyle(fontSize: 48)), - const SizedBox(height: AppSpacing.sm), - Text( - '小组件管理', - style: AppTypography.headline.copyWith( - color: ext.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: AppSpacing.xs), - Text( - '添加和管理桌面小组件', - style: AppTypography.caption1.copyWith( - color: ext.textHint, - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } -} - -/// 句子来源面板 -class SourcePanel extends ConsumerWidget { - const SourcePanel({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final ext = AppTheme.ext(context); - return SettingsPanelWrapper( - title: '句子来源', - icon: CupertinoIcons.book, - child: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '管理句子数据来源', - style: AppTypography.subhead.copyWith(color: ext.textSecondary), - ), - const SizedBox(height: AppSpacing.md), - GlassContainer( - depth: GlassDepth.elevated, - padding: const EdgeInsets.all(AppSpacing.md), - child: Center( - child: Column( - children: [ - const Text('📚', style: TextStyle(fontSize: 48)), - const SizedBox(height: AppSpacing.sm), - Text( - '来源管理', - style: AppTypography.headline.copyWith( - color: ext.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: AppSpacing.xs), - Text( - '查看和管理句子来源渠道', - style: AppTypography.caption1.copyWith( - color: ext.textHint, - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } -} - -/// 关于面板 -class AboutPanel extends ConsumerWidget { - const AboutPanel({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final ext = AppTheme.ext(context); - return SettingsPanelWrapper( - title: '关于', - icon: CupertinoIcons.info_circle, - child: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GlassContainer( - depth: GlassDepth.elevated, - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - children: [ - const Text('💬', style: TextStyle(fontSize: 56)), - const SizedBox(height: AppSpacing.sm), - Text( - '闲言', - style: AppTypography.title1.copyWith( - color: ext.textPrimary, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: AppSpacing.xs), - Text( - 'Version 1.0.0', - style: AppTypography.subhead.copyWith(color: ext.textHint), - ), - const SizedBox(height: AppSpacing.sm), - Text( - '发现有趣的句子与灵感 ✨', - style: AppTypography.body.copyWith( - color: ext.textSecondary, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - const SizedBox(height: AppSpacing.md), - GlassContainer( - depth: GlassDepth.elevated, - padding: EdgeInsets.zero, - child: Column( - children: [ - _buildAboutItem(ext, '📄', '用户协议'), - _buildAboutItem(ext, '🔒', '隐私政策'), - _buildAboutItem(ext, '⭐', '给我们评分'), - _buildAboutItem(ext, '📧', '联系我们'), - _buildAboutItem(ext, '🔄', '检查更新'), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildAboutItem(AppThemeExtension ext, String emoji, String title) { - return CupertinoButton( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - onPressed: null, - child: Row( - children: [ - Text(emoji, style: const TextStyle(fontSize: 18)), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - title, - style: AppTypography.body.copyWith(color: ext.textPrimary), - ), - ), - Icon(CupertinoIcons.chevron_right, size: 14, color: ext.textHint), - ], - ), - ); - } -} - -/// 注册所有设置面板到 RightPanelRegistry -void registerSettingsPanels() { - RightPanelRegistry.registerAll({ - 'account_settings': (context, args) => const AccountSettingsPanel(), - 'data_management': (context, args) => const DataManagementPanel(), - 'offline_mode': (context, args) => const OfflineModePanel(), - 'cache_management': (context, args) => const CacheManagementPanel(), - 'language_settings': (context, args) => const LanguageSettingsPanel(), - 'theme_settings': (context, args) => const ThemeSettingsPanel(), - 'general_settings': (context, args) => const GeneralSettingsPanel(), - 'widget_management': (context, args) => const WidgetManagementPanel(), - 'source': (context, args) => const SourcePanel(), - 'about': (context, args) => const AboutPanel(), - }); -} diff --git a/lib/features/settings/presentation/plugin/translate_plugin_page.dart b/lib/features/settings/presentation/plugin/translate_plugin_page.dart index 87519905..159051f2 100644 --- a/lib/features/settings/presentation/plugin/translate_plugin_page.dart +++ b/lib/features/settings/presentation/plugin/translate_plugin_page.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 翻译守护插件详情页 // 创建时间: 2026-05-25 -// 更新时间: 2026-05-25 +// 更新时间: 2026-06-19 // 作用: 翻译守护插件配置、使用预览和记录 -// 上次更新: 新增插件健康状态指示器 +// 上次更新: 修复Provider生命周期隐患——进入页面主动refresh翻译记录 // ============================================================ import 'package:flutter/cupertino.dart'; @@ -79,6 +79,12 @@ class _TranslatePluginPageState extends ConsumerState { _loadTrendData(); _checkHealth(); }).catchError((_) {}); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + // 翻译记录:每次进入页面主动刷新,确保展示最新数据 + //(translateRecordListProvider 是非 autoDispose,重复进入会复用旧状态) + ref.read(translateRecordListProvider.notifier).refresh(); + }); } /// 检查翻译插件健康状态 diff --git a/lib/features/settings/presentation/plugin/tts_plugin_page.dart b/lib/features/settings/presentation/plugin/tts_plugin_page.dart index 85745be2..1ea7eed6 100644 --- a/lib/features/settings/presentation/plugin/tts_plugin_page.dart +++ b/lib/features/settings/presentation/plugin/tts_plugin_page.dart @@ -1,11 +1,10 @@ /// ============================================================ /// 闲言APP — 文本朗读插件详情页 /// 创建时间: 2026-05-25 -/// 更新时间: 2026-06-12 +/// 更新时间: 2026-06-19 /// 作用: 文本朗读插件配置、使用预览和记录 -/// 上次更新: 集成ttsPlaybackProvider驱动实时播放状态、 -/// 健康检查loading、反馈改用ContactEmailSheet、 -/// 记录项增加重播、预览区播放/停止切换+动态进度 +/// 上次更新: 修复Provider生命周期隐患——进入页面主动refresh TTS记录、 +/// 集成ttsPlaybackProvider驱动实时播放状态、健康检查loading /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -52,6 +51,12 @@ class _TtsPluginPageState extends ConsumerState { void initState() { super.initState(); Future.microtask(() => _checkHealth()).catchError((_) {}); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + // TTS记录:每次进入页面主动刷新,确保展示最新数据 + //(ttsRecordListProvider 是非 autoDispose,重复进入会复用旧状态) + ref.read(ttsRecordListProvider.notifier).refresh(); + }); } /// 检查TTS插件健康状态(带loading态) diff --git a/lib/features/settings/presentation/privacy/crash_log_page.dart b/lib/features/settings/presentation/privacy/crash_log_page.dart index dd45c0d7..3e944ecb 100644 --- a/lib/features/settings/presentation/privacy/crash_log_page.dart +++ b/lib/features/settings/presentation/privacy/crash_log_page.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 崩溃日志管理页面 /// 创建时间: 2026-05-21 -/// 更新时间: 2026-05-21 +/// 更新时间: 2026-06-19 /// 作用: 查看/复制/删除/清空/导出崩溃日志 -/// 上次更新: 初始创建 +/// 上次更新: 删除日志后页面pop改用 context.appPop(),修复工作台模式下 Navigator.pop 弹空根栈导致白屏 /// ============================================================ import 'dart:io'; @@ -14,6 +14,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:share_plus/share_plus.dart'; +import '../../../../core/router/app_nav_extension.dart'; import '../../../../core/services/crash_log_service.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../core/theme/app_spacing.dart'; @@ -387,7 +388,7 @@ class _CrashLogDetailPage extends ConsumerWidget { ); if (confirmed == true) { await ref.read(crashLogServiceProvider).deleteLog(entry.id); - if (context.mounted) Navigator.of(context).pop(); + if (context.mounted) context.appPop(); } } diff --git a/lib/features/settings/presentation/theme/theme_sections_preview.dart b/lib/features/settings/presentation/theme/theme_sections_preview.dart index 0e08acf4..8140ec7d 100644 --- a/lib/features/settings/presentation/theme/theme_sections_preview.dart +++ b/lib/features/settings/presentation/theme/theme_sections_preview.dart @@ -1,15 +1,16 @@ /// ============================================================ /// 闲言APP — 主题预览与分享区块 /// 创建时间: 2026-05-19 -/// 更新时间: 2026-05-31 +/// 更新时间: 2026-06-19 /// 作用: 实时预览卡片/动画演示/主题导出导入 -/// 上次更新: i18n支持,替换硬编码中文为t.theme.xxx引用 +/// 上次更新: 类型安全修复(int vs num): 主题导入颜色/时间使用 SafeJson.parseInt /// ============================================================ import 'dart:convert'; import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../../../core/theme/app_radius.dart'; import '../../../../core/theme/app_spacing.dart'; @@ -464,7 +465,7 @@ class ShareSection extends ConsumerWidget { if (data['accentColorId'] == 'custom' && data['customAccentColor'] != null) { notifier.setCustomAccentColor( - data['customAccentColor'] as int, + SafeJson.parseInt(data['customAccentColor']), ); } else { notifier.setAccentColor(data['accentColorId'] as String); @@ -501,8 +502,8 @@ class ShareSection extends ConsumerWidget { if (data['darkScheduleStart'] != null && data['darkScheduleEnd'] != null) { notifier.setDarkSchedule( - data['darkScheduleStart'] as int, - data['darkScheduleEnd'] as int, + SafeJson.parseInt(data['darkScheduleStart']), + SafeJson.parseInt(data['darkScheduleEnd']), ); } diff --git a/lib/features/settings/presentation/workbench/workbench_settings_page.dart b/lib/features/settings/presentation/workbench/workbench_settings_page.dart new file mode 100644 index 00000000..d017118a --- /dev/null +++ b/lib/features/settings/presentation/workbench/workbench_settings_page.dart @@ -0,0 +1,410 @@ +/// ============================================================ +/// 闲言APP — 工作台模式设置页面 +/// 创建时间: 2026-06-18 +/// 更新时间: 2026-06-18 +/// 作用: 整合分屏/工作台相关设置为独立页面,扩展交互增强功能 +/// 上次更新: 修复编译错误,对齐 AppThemeExtension/SplitViewState 字段名,使用 i18n 翻译键 +/// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/providers/split_view_provider.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/utils/platform/platform_utils.dart' as pu; +import '../../../../l10n/translations.dart'; + +/// 工作台模式设置页面 +/// +/// 整合通用设置中的 4 项分屏/工作台相关设置: +/// 1. 工作台模式开关(workbenchEnabled) +/// 2. 导航栏位置(navBarPosition) +/// 3. 分屏比例(splitRatio) +/// 4. 分屏开关(splitViewEnabled) +/// +/// 扩展交互增强功能(占位,后续迭代): +/// - 专注阅读模式 +/// - 右栏分屏 +/// - 拖拽出窗 +/// - 右栏标签页 +/// - 中栏拖拽排序 +/// - 毛玻璃背景 +/// - 右栏动画 +/// - 空状态动画 +class WorkbenchSettingsPage extends ConsumerWidget { + const WorkbenchSettingsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ext = AppTheme.ext(context); + final splitState = ref.watch(splitViewProvider); + final t = ref.watch(translationsProvider); + final display = t.settings.display; + + return CupertinoPageScaffold( + backgroundColor: ext.bgPrimary, + navigationBar: CupertinoNavigationBar( + middle: Text(display.workbenchEnabled), + backgroundColor: ext.bgPrimary.withValues(alpha: 0.9), + ), + child: SafeArea( + child: ListView( + children: [ + // ============================================================ + // 第 1 组:工作台模式 + // ============================================================ + _SectionHeader(title: display.workbenchEnabled, ext: ext), + _SettingsGroup( + ext: ext, + children: [ + _SwitchTile( + title: display.workbenchEnabled, + subtitle: display.workbenchEnabledSubtitle, + value: splitState.workbenchEnabled, + onChanged: (v) => ref + .read(splitViewProvider.notifier) + .setWorkbenchEnabled(v), + ext: ext, + ), + _SwitchTile( + title: display.splitViewEnabled, + subtitle: display.splitViewEnabledSubtitle, + value: splitState.splitViewEnabled, + onChanged: (v) => ref + .read(splitViewProvider.notifier) + .setSplitViewEnabled(v), + ext: ext, + ), + ], + ), + + // ============================================================ + // 第 2 组:布局配置 + // ============================================================ + _SectionHeader(title: display.navBarPositionTitle, ext: ext), + _SettingsGroup( + ext: ext, + children: [ + _SegmentedTile( + title: display.navBarPosition, + subtitle: display.navBarPositionSubtitle, + value: splitState.navBarPosition.index, + segments: NavBarPosition.values + .map((p) => '${p.emoji} ${p.label}') + .toList(), + onChanged: (index) => ref + .read(splitViewProvider.notifier) + .setNavBarPosition(NavBarPosition.values[index]), + ext: ext, + ), + _SliderTile( + title: display.splitViewRatio, + subtitle: + '${display.splitViewRatioSubtitle}\n${(splitState.splitRatio * 100).toInt()}:${(100 - splitState.splitRatio * 100).toInt()}', + value: splitState.splitRatio, + min: 0.30, + max: 0.60, + divisions: 6, + onChanged: (v) => ref + .read(splitViewProvider.notifier) + .setSplitRatio(v), + ext: ext, + ), + ], + ), + + // ============================================================ + // 第 3 组:交互增强 + // ============================================================ + if (pu.isDesktop) ...[ + _SectionHeader(title: '交互增强', ext: ext), + _SettingsGroup( + ext: ext, + children: [ + _SwitchTile( + title: '专注阅读模式', + subtitle: '隐藏导航栏和中栏,专注右栏内容', + value: splitState.focusReadingMode, + onChanged: (v) => ref + .read(splitViewProvider.notifier) + .setFocusReadingMode(v), + ext: ext, + ), + _SwitchTile( + title: '右栏分屏', + subtitle: '超宽屏(≥1920px)时右栏再分屏', + value: splitState.rightPanelSplit, + onChanged: (v) => ref + .read(splitViewProvider.notifier) + .setRightPanelSplit(v), + ext: ext, + ), + _SwitchTile( + title: '拖拽出窗', + subtitle: '支持将右栏内容拖拽为独立窗口(占位)', + value: splitState.popOutWindow, + onChanged: (v) => ref + .read(splitViewProvider.notifier) + .setPopOutWindow(v), + ext: ext, + ), + _SwitchTile( + title: '右栏标签页', + subtitle: '右栏支持多标签页切换(占位)', + value: splitState.rightPanelTabs, + onChanged: (v) => ref + .read(splitViewProvider.notifier) + .setRightPanelTabs(v), + ext: ext, + ), + _SwitchTile( + title: '中栏拖拽排序', + subtitle: '中栏列表支持长按拖拽排序', + value: splitState.middlePanelDragSort, + onChanged: (v) => ref + .read(splitViewProvider.notifier) + .setMiddlePanelDragSort(v), + ext: ext, + ), + ], + ), + ], + + // ============================================================ + // 第 4 组:视觉增强 + // ============================================================ + if (pu.isDesktop) ...[ + _SectionHeader(title: '视觉增强', ext: ext), + _SettingsGroup( + ext: ext, + children: [ + _SwitchTile( + title: '毛玻璃背景', + subtitle: '工作台背景使用毛玻璃效果', + value: splitState.workbenchBlurBackground, + onChanged: (v) => ref + .read(splitViewProvider.notifier) + .setWorkbenchBlurBackground(v), + ext: ext, + ), + _SwitchTile( + title: '空状态动画', + subtitle: '空状态显示动画角色', + value: splitState.emptyStateAnimation, + onChanged: (v) => ref + .read(splitViewProvider.notifier) + .setEmptyStateAnimation(v), + ext: ext, + ), + ], + ), + ], + + const SizedBox(height: 32), + ], + ), + ), + ); + } +} + +// ============================================================ +// 私有组件 +// ============================================================ + +/// 分组标题 +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title, required this.ext}); + + final String title; + final AppThemeExtension ext; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 8), + child: Text( + title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: ext.textSecondary, + letterSpacing: 0.5, + ), + ), + ); + } +} + +/// 设置分组容器 +class _SettingsGroup extends StatelessWidget { + const _SettingsGroup({required this.ext, required this.children}); + + final AppThemeExtension ext; + final List children; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: ext.bgCard, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + for (int i = 0; i < children.length; i++) ...[ + children[i], + if (i < children.length - 1) + Divider(height: 1, indent: 16, color: ext.dividerOnCard), + ], + ], + ), + ); + } +} + +/// 开关项 +class _SwitchTile extends StatelessWidget { + const _SwitchTile({ + required this.title, + required this.subtitle, + required this.value, + required this.onChanged, + required this.ext, + }); + + final String title; + final String subtitle; + final bool value; + final ValueChanged onChanged; + final AppThemeExtension ext; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: TextStyle(fontSize: 16, color: ext.textPrimary)), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle(fontSize: 13, color: ext.textHint), + ), + ], + ), + ), + CupertinoSwitch(value: value, onChanged: onChanged), + ], + ), + ); + } +} + +/// 分段选择项 +class _SegmentedTile extends StatelessWidget { + const _SegmentedTile({ + required this.title, + required this.subtitle, + required this.value, + required this.segments, + required this.onChanged, + required this.ext, + }); + + final String title; + final String subtitle; + final int value; + final List segments; + final ValueChanged onChanged; + final AppThemeExtension ext; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: TextStyle(fontSize: 16, color: ext.textPrimary)), + const SizedBox(height: 2), + Text(subtitle, + style: TextStyle(fontSize: 13, color: ext.textHint)), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: CupertinoSegmentedControl( + groupValue: value, + children: { + for (int i = 0; i < segments.length; i++) + i: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + child: Text(segments[i], style: const TextStyle(fontSize: 13)), + ), + }, + onValueChanged: onChanged, + ), + ), + ], + ), + ); + } +} + +/// 滑块项 +class _SliderTile extends StatelessWidget { + const _SliderTile({ + required this.title, + required this.subtitle, + required this.value, + required this.min, + required this.max, + required this.divisions, + required this.onChanged, + required this.ext, + }); + + final String title; + final String subtitle; + final double value; + final double min; + final double max; + final int divisions; + final ValueChanged onChanged; + final AppThemeExtension ext; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: TextStyle(fontSize: 16, color: ext.textPrimary)), + const SizedBox(height: 2), + Text(subtitle, + style: TextStyle(fontSize: 13, color: ext.textHint)), + Slider( + value: value, + min: min, + max: max, + divisions: divisions, + onChanged: onChanged, + ), + ], + ), + ); + } +} diff --git a/lib/features/settings/services/settings_change_logger.dart b/lib/features/settings/services/settings_change_logger.dart index b033867d..340dfb59 100644 --- a/lib/features/settings/services/settings_change_logger.dart +++ b/lib/features/settings/services/settings_change_logger.dart @@ -1,14 +1,15 @@ /// ============================================================ /// 闲言APP — 设置变更日志 /// 创建时间: 2026-05-24 -/// 更新时间: 2026-05-24 +/// 更新时间: 2026-06-19 /// 作用: 记录用户修改了哪些设置,方便回溯 -/// 上次更新: 初始创建 +/// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0 /// ============================================================ import 'dart:convert'; import 'package:hive_ce_flutter/hive_flutter.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../../core/utils/logger.dart'; @@ -38,7 +39,7 @@ class SettingsChangeEntry { oldValue: json['oldValue'] as String? ?? '', newValue: json['newValue'] as String? ?? '', changedAt: DateTime.fromMillisecondsSinceEpoch( - json['changedAt'] as int? ?? 0, + SafeJson.parseInt(json['changedAt']), ), ); } diff --git a/lib/features/solar_term/solar_term_core.dart b/lib/features/solar_term/solar_term_core.dart index c536f0f1..da74ee6c 100644 --- a/lib/features/solar_term/solar_term_core.dart +++ b/lib/features/solar_term/solar_term_core.dart @@ -1,12 +1,13 @@ /// ============================================================ /// 闲言APP — 节气核心模块(模型 + 服务 + 状态管理) /// 创建时间: 2026-06-12 -/// 更新时间: 2026-06-17 +/// 更新时间: 2026-06-19 /// 作用: 合并节气数据模型、日期计算服务、状态管理为一体 -/// 上次更新: 修复节气日期表排序(小寒大寒移至年初)+修正2026雨水日期+getCurrentTerm跨年边界 +/// 上次更新: 类型安全修复(int vs num): 节气 month/day/index 使用 SafeJson.parseInt /// ============================================================ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../core/utils/logger.dart'; @@ -391,10 +392,10 @@ class SolarTermService { SolarTermInfo? current; for (final entry in terms) { - final date = DateTime(year, entry['month'] as int, entry['day'] as int); + final date = DateTime(year, SafeJson.parseInt(entry['month']), SafeJson.parseInt(entry['day'])); if (date.isBefore(now) || date.isAtSameMomentAs(DateTime(now.year, now.month, now.day))) { - current = SolarTermInfo.all[entry['index'] as int]; + current = SolarTermInfo.all[SafeJson.parseInt(entry['index'])]; } } @@ -402,7 +403,7 @@ class SolarTermService { if (current == null) { final prevTerms = _getTermsForYear(year - 1); if (prevTerms.isNotEmpty) { - current = SolarTermInfo.all[prevTerms.last['index'] as int]; + current = SolarTermInfo.all[SafeJson.parseInt(prevTerms.last['index'])]; } } @@ -416,15 +417,15 @@ class SolarTermService { final terms = _getTermsForYear(year); for (final entry in terms) { - final date = DateTime(year, entry['month'] as int, entry['day'] as int); + final date = DateTime(year, SafeJson.parseInt(entry['month']), SafeJson.parseInt(entry['day'])); if (date.isAfter(now)) { - return SolarTermInfo.all[entry['index'] as int]; + return SolarTermInfo.all[SafeJson.parseInt(entry['index'])]; } } final nextYearTerms = _getTermsForYear(year + 1); if (nextYearTerms.isNotEmpty) { - return SolarTermInfo.all[nextYearTerms.first['index'] as int]; + return SolarTermInfo.all[SafeJson.parseInt(nextYearTerms.first['index'])]; } return null; } @@ -436,7 +437,7 @@ class SolarTermService { final terms = _getTermsForYear(year); for (final entry in terms) { - final date = DateTime(year, entry['month'] as int, entry['day'] as int); + final date = DateTime(year, SafeJson.parseInt(entry['month']), SafeJson.parseInt(entry['day'])); if (date.isAfter(now)) { return date.difference(now).inDays; } @@ -446,8 +447,8 @@ class SolarTermService { if (nextYearTerms.isNotEmpty) { final date = DateTime( year + 1, - nextYearTerms.first['month'] as int, - nextYearTerms.first['day'] as int, + SafeJson.parseInt(nextYearTerms.first['month']), + SafeJson.parseInt(nextYearTerms.first['day']), ); return date.difference(now).inDays; } @@ -460,8 +461,8 @@ class SolarTermService { return terms .map( (e) => { - 'info': SolarTermInfo.all[e['index'] as int], - 'date': DateTime(year, e['month'] as int, e['day'] as int), + 'info': SolarTermInfo.all[SafeJson.parseInt(e['index'])], + 'date': DateTime(year, SafeJson.parseInt(e['month']), SafeJson.parseInt(e['day'])), }, ) .toList(); @@ -495,7 +496,7 @@ class SolarTermService { for (final entry in terms) { if (entry['month'] == now.month && entry['day'] == now.day) { - return SolarTermInfo.all[entry['index'] as int]; + return SolarTermInfo.all[SafeJson.parseInt(entry['index'])]; } } return null; diff --git a/lib/features/study_plan/presentation/study_plan_page.dart b/lib/features/study_plan/presentation/study_plan_page.dart index 28330548..33233a1d 100644 --- a/lib/features/study_plan/presentation/study_plan_page.dart +++ b/lib/features/study_plan/presentation/study_plan_page.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 学习计划页面 /// 创建时间: 2026-05-02 -/// 更新时间: 2026-06-17 +/// 更新时间: 2026-06-19 /// 作用: 学习计划列表 + 进度环 + 新建/完成/删除 + 阅读目标 -/// 上次更新: emoji替换为CupertinoIcons,多语言支持,修复创建bug,iOS26风格重设计 +/// 上次更新: 创建计划后页面pop改用 context.appPop(),修复工作台模式下 Navigator.pop 弹空根栈导致白屏 /// ============================================================ import 'dart:math'; @@ -1016,7 +1016,7 @@ class _CreatePlanSheetState extends ConsumerState<_CreatePlanSheet> { category: _selectedCategory, dailyGoal: _dailyGoal, ); - if (mounted) Navigator.pop(context); + if (mounted) context.appPop(); } String _categoryLabel(TStudyPlan sp, PlanCategory category) { diff --git a/lib/features/study_plan/providers/reading_goal_provider.dart b/lib/features/study_plan/providers/reading_goal_provider.dart index d4a6e397..a5aec8dd 100644 --- a/lib/features/study_plan/providers/reading_goal_provider.dart +++ b/lib/features/study_plan/providers/reading_goal_provider.dart @@ -1,12 +1,13 @@ /// ============================================================ /// 闲言APP — 阅读目标状态管理 /// 创建时间: 2026-05-24 -/// 更新时间: 2026-05-24 +/// 更新时间: 2026-06-19 /// 作用: 每日阅读目标设置与进度追踪 -/// 上次更新: 初始创建 +/// 上次更新: 修复dailyProgress永远为0——添加refreshDailyProgress从本地DB获取今日进度 /// ============================================================ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/storage/database/app_database.dart'; import '../../../core/storage/kv_storage.dart'; import '../../../core/utils/logger.dart'; @@ -31,6 +32,26 @@ class ReadingGoal { final int streakDays; final DailyReadingProgress dailyProgress; + /// 今日阅读目标完成度(0.0 - 1.0) + double get overallProgress { + final viewP = dailyViewGoal > 0 + ? (dailyProgress.viewsToday / dailyViewGoal).clamp(0.0, 1.0) + : 0.0; + final favP = dailyFavoriteGoal > 0 + ? (dailyProgress.favoritesToday / dailyFavoriteGoal).clamp(0.0, 1.0) + : 0.0; + final noteP = dailyNoteGoal > 0 + ? (dailyProgress.notesToday / dailyNoteGoal).clamp(0.0, 1.0) + : 0.0; + return (viewP + favP + noteP) / 3; + } + + /// 今日阅读目标是否全部完成 + bool get isAllCompleted => + dailyProgress.viewsToday >= dailyViewGoal && + dailyProgress.favoritesToday >= dailyFavoriteGoal && + dailyProgress.notesToday >= dailyNoteGoal; + ReadingGoal copyWith({ int? dailyViewGoal, int? dailyFavoriteGoal, @@ -80,16 +101,25 @@ class DailyReadingProgress { class ReadingGoalNotifier extends Notifier { static const _prefix = 'reading_goal_'; + static const _lastCheckDateKey = '${_prefix}last_check_date'; @override ReadingGoal build() { - Future.microtask(() => _loadFromStorage()).catchError((_) {}); + Future.microtask(() => _init()).catchError((_) {}); return const ReadingGoal(); } + /// 初始化:加载存储的目标 + 刷新今日进度 + 更新连续天数 + Future _init() async { + await _loadFromStorage(); + await refreshDailyProgress(); + await _updateStreakDays(); + } + + /// 从本地存储加载阅读目标设置 Future _loadFromStorage() async { try { - state = ReadingGoal( + state = state.copyWith( dailyViewGoal: KvStorage.getInt('${_prefix}view_goal') ?? 10, dailyFavoriteGoal: KvStorage.getInt('${_prefix}fav_goal') ?? 3, dailyNoteGoal: KvStorage.getInt('${_prefix}note_goal') ?? 1, @@ -101,6 +131,108 @@ class ReadingGoalNotifier extends Notifier { } } + /// 从本地数据库刷新今日阅读进度 + /// + /// 数据来源: + /// - viewsToday: ReadHistory表今日记录数 + /// - favoritesToday: Sentences表今日updatedAt且isFavorite=true的记录数 + /// - notesToday: 暂无笔记表,保持0(后续可扩展) + Future refreshDailyProgress() async { + try { + final db = AppDatabase.instance; + final now = DateTime.now(); + final todayStart = DateTime(now.year, now.month, now.day); + + final viewsToday = await db.getReadHistoryCountSince(todayStart); + final favoritesToday = await db.getFavoriteCountSince(todayStart); + + state = state.copyWith( + dailyProgress: DailyReadingProgress( + viewsToday: viewsToday, + favoritesToday: favoritesToday, + notesToday: 0, // 笔记功能后续扩展 + ), + ); + Log.i('阅读目标进度刷新: views=$viewsToday favs=$favoritesToday'); + } catch (e) { + Log.e('阅读目标进度刷新失败', e); + } + } + + /// 更新连续打卡天数 + /// + /// 逻辑: + /// - 若今天已检查过(_lastCheckDateKey == today),不重复更新 + /// - 若今日阅读目标全部完成,streakDays + 1 + /// - 若昨日未完成目标且今日未完成,streakDays 重置为0 + Future _updateStreakDays() async { + try { + final now = DateTime.now(); + final todayStr = '${now.year}-${now.month}-${now.day}'; + final lastCheckDate = KvStorage.getString(_lastCheckDateKey); + + // 今天已检查过,跳过 + if (lastCheckDate == todayStr) return; + + // 检查昨日是否完成目标 + final yesterday = now.subtract(const Duration(days: 1)); + final yesterdayStart = DateTime(yesterday.year, yesterday.month, yesterday.day); + final todayStart = DateTime(now.year, now.month, now.day); + + final db = AppDatabase.instance; + final yesterdayViews = await db.getReadHistoryCountSince(yesterdayStart) - + await db.getReadHistoryCountSince(todayStart); + + // 简化判断:昨日有阅读记录则视为连续 + final yesterdayCompleted = yesterdayViews > 0; + + if (state.isAllCompleted) { + // 今日目标完成,连续天数+1 + final newStreak = state.streakDays + 1; + state = state.copyWith(streakDays: newStreak); + KvStorage.setInt('${_prefix}streak_days', newStreak); + } else if (!yesterdayCompleted && state.streakDays > 0) { + // 昨日未完成且今日未完成,重置连续天数 + state = state.copyWith(streakDays: 0); + KvStorage.setInt('${_prefix}streak_days', 0); + } + + KvStorage.setString(_lastCheckDateKey, todayStr); + } catch (e) { + Log.e('连续天数更新失败', e); + } + } + + /// 手动增加今日阅读数(用户阅读新句子时调用) + void incrementViews() { + final current = state.dailyProgress; + state = state.copyWith( + dailyProgress: current.copyWith( + viewsToday: current.viewsToday + 1, + ), + ); + } + + /// 手动增加今日收藏数(用户收藏时调用) + void incrementFavorites() { + final current = state.dailyProgress; + state = state.copyWith( + dailyProgress: current.copyWith( + favoritesToday: current.favoritesToday + 1, + ), + ); + } + + /// 手动增加今日笔记数(用户记笔记时调用) + void incrementNotes() { + final current = state.dailyProgress; + state = state.copyWith( + dailyProgress: current.copyWith( + notesToday: current.notesToday + 1, + ), + ); + } + void setViewGoal(int v) { state = state.copyWith(dailyViewGoal: v); KvStorage.setInt('${_prefix}view_goal', v); diff --git a/lib/features/task/daily_task_page.dart b/lib/features/task/daily_task_page.dart index 90cbf5e1..a7b58a68 100644 --- a/lib/features/task/daily_task_page.dart +++ b/lib/features/task/daily_task_page.dart @@ -1,11 +1,12 @@ /// @name 每日任务页面 /// @date 2026-05-14 /// @desc 展示今日任务列表+进度+领取+完美日 -/// @update v6.0 全面重构: 动态主题/GlassContainer/i18n/空状态/AppTypography +/// @update 2026-06-19 类型安全修复(int vs num): _showRewardDialog 使用 SafeJson.parseInt import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import 'task_core.dart'; import '../../shared/widgets/cards/task_card.dart'; @@ -348,8 +349,8 @@ class _DailyTaskPageState extends ConsumerState { void _showRewardDialog(String name, Map data) { final t = ref.read(translationsProvider); - final expReward = (data['exp_reward'] ?? 0) as int; - final scoreReward = (data['score_reward'] ?? 0) as int; + final expReward = SafeJson.parseInt(data['exp_reward']); + final scoreReward = SafeJson.parseInt(data['score_reward']); showCupertinoDialog( context: context, diff --git a/lib/features/task/task_core.dart b/lib/features/task/task_core.dart index 7235dfa9..5b5543a3 100644 --- a/lib/features/task/task_core.dart +++ b/lib/features/task/task_core.dart @@ -1,9 +1,10 @@ /// @name 每日任务核心模块 /// @date 2026-06-12 /// @desc 合并自 task_service.dart + task_provider.dart,包含API服务与状态管理 -/// @update 2026-06-12 初始合并: TaskService + DailyTask/TaskSummary/TaskState/TaskNotifier/taskProvider +/// @update 2026-06-19 类型安全修复(int vs num): DailyTask/TaskSummary.fromJson 使用 SafeJson.parseInt import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../core/network/api_client.dart'; // ============================================================ @@ -112,21 +113,21 @@ class DailyTask { factory DailyTask.fromJson(Map json) { return DailyTask( - id: (json['id'] ?? 0) as int, + id: SafeJson.parseInt(json['id']), name: (json['name'] ?? '') as String, icon: (json['icon'] ?? '📋') as String, type: (json['type'] ?? '') as String, - target: (json['target'] ?? 1) as int, + target: SafeJson.parseInt(json['target'], 1), action: (json['action'] ?? '') as String, customUrl: (json['custom_url'] ?? '') as String, customPage: (json['custom_page'] ?? '') as String, - expReward: (json['exp_reward'] ?? 0) as int, - scoreReward: (json['score_reward'] ?? 0) as int, - isRandom: (json['is_random'] ?? 0) as int, - progress: (json['progress'] ?? 0) as int, + expReward: SafeJson.parseInt(json['exp_reward']), + scoreReward: SafeJson.parseInt(json['score_reward']), + isRandom: SafeJson.parseInt(json['is_random']), + progress: SafeJson.parseInt(json['progress']), completed: json['completed'] == 1 || json['completed'] == true, claimed: json['claimed'] == 1 || json['claimed'] == true, - percent: (json['percent'] ?? 0) as int, + percent: SafeJson.parseInt(json['percent']), ); } } @@ -151,9 +152,9 @@ class TaskSummary { factory TaskSummary.fromJson(Map json) { return TaskSummary( - total: (json['total'] ?? 0) as int, - completed: (json['completed'] ?? 0) as int, - claimed: (json['claimed'] ?? 0) as int, + total: SafeJson.parseInt(json['total']), + completed: SafeJson.parseInt(json['completed']), + claimed: SafeJson.parseInt(json['claimed']), isPerfectDay: json['is_perfect_day'] == true || json['is_perfect_day'] == 1, perfectClaimed: diff --git a/lib/features/template/models/template_models.dart b/lib/features/template/models/template_models.dart index cc098cd2..423f3666 100644 --- a/lib/features/template/models/template_models.dart +++ b/lib/features/template/models/template_models.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 壁纸模板数据模型 /// 创建时间: 2026-05-01 -/// 更新时间: 2026-05-24 +/// 更新时间: 2026-06-19 // 作用: 壁纸图库数据结构 — 壁纸项/分类/来源/模板 -// 上次更新: 添加isFast getter区分快速源/慢速源,优化壁纸加载策略 +// 上次更新: 修复筛选类型与实际不一致——不支持分类的源跳过category参数 /// ============================================================ // ============================================================ @@ -39,6 +39,33 @@ enum WallpaperSource { bool get isFast => avgMs < 500; + /// 是否支持分类筛选 + /// + /// 以下源不支持分类筛选(返回随机或固定内容): + /// - bing360 / bing: 必应每日壁纸,无分类 + /// - nasa: NASA每日天文图片,无分类 + /// - dmoe: 随机二次元壁纸,无分类 + /// - animePictures: 随机动漫插画,无分类 + /// - bizhiduoduo: action=random 模式,category 无效 + bool get supportsCategory { + switch (this) { + case WallpaperSource.bing360: + case WallpaperSource.bing: + case WallpaperSource.nasa: + case WallpaperSource.dmoe: + case WallpaperSource.animePictures: + case WallpaperSource.bizhiduoduo: + return false; + case WallpaperSource.unsplash: + case WallpaperSource.wallstreet: + case WallpaperSource.pexels: + case WallpaperSource.pixabay: + case WallpaperSource.haoWallpaper: + case WallpaperSource.bingEnhanced: + return true; + } + } + String get baseUrl => 'http://bz.wktyl.com'; String buildUrl({ @@ -56,7 +83,12 @@ enum WallpaperSource { } else if (this == WallpaperSource.bizhiduoduo) { params['action'] = 'random'; } - if (category != null && category != 'all') params['category'] = category; + // 仅对支持分类筛选的源传 category 参数 + if (category != null && + category != 'all' && + supportsCategory) { + params['category'] = category; + } if (sort != 'hot') params['sort'] = sort; if (search != null && search.isNotEmpty) params['search'] = search; diff --git a/lib/features/template/services/wallpaper_health_service.dart b/lib/features/template/services/wallpaper_health_service.dart index 3f2cf0bc..75ac29c9 100644 --- a/lib/features/template/services/wallpaper_health_service.dart +++ b/lib/features/template/services/wallpaper_health_service.dart @@ -1,14 +1,15 @@ /// ============================================================ /// 闲言APP — 壁纸源健康检测服务 /// 创建时间: 2026-05-24 -/// 更新时间: 2026-05-26 +/// 更新时间: 2026-06-19 /// 作用: 定期检测各壁纸源可用性和响应速度,动态调整优先级 -/// 上次更新: checkAllSources改为Future.wait并行检测+单源超时15秒 +/// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0 /// ============================================================ import 'dart:convert'; import 'package:hive_ce_flutter/hive_flutter.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../../../core/utils/logger.dart'; import '../models/template_models.dart'; @@ -47,10 +48,10 @@ class SourceHealth { (s) => s.name == json['source'], orElse: () => WallpaperSource.unsplash, ), - avgMs: json['avgMs'] as int? ?? 9999, + avgMs: SafeJson.parseInt(json['avgMs'], 9999), successRate: (json['successRate'] as num?)?.toDouble() ?? 0.0, lastCheckAt: DateTime.fromMillisecondsSinceEpoch( - json['lastCheckAt'] as int? ?? 0, + SafeJson.parseInt(json['lastCheckAt']), ), isAvailable: json['isAvailable'] as bool? ?? false, ); diff --git a/lib/features/template/wallpaper_gallery/wallpaper_gallery_view.dart b/lib/features/template/wallpaper_gallery/wallpaper_gallery_view.dart index 839111f4..07e7ca9a 100644 --- a/lib/features/template/wallpaper_gallery/wallpaper_gallery_view.dart +++ b/lib/features/template/wallpaper_gallery/wallpaper_gallery_view.dart @@ -1,10 +1,11 @@ /// ============================================================ /// 闲言APP — 壁纸公共组件主体 /// 创建时间: 2026-05-04 -/// 更新时间: 2026-06-12 +/// 更新时间: 2026-06-19 /// 作用: 壁纸图库公共视图 — 支持drawer(编辑器抽屉)/fullscreen(发现页)双模式 /// 统一数据源 + 瀑布流 + 已加载优先 + 分类"全部" + URL三级回退 + 无限下拉加载 -/// 上次更新: 从shared/widgets迁移至features/template模块,简化template导入路径 +/// + 离线缓存浏览模式(基于_cachedUrls过滤,不发起网络请求) +/// 上次更新: 新增离线缓存浏览功能,源栏增加"已缓存"入口,离线模式禁用刷新/分页 /// ============================================================ import 'dart:async'; @@ -57,6 +58,9 @@ class _WallpaperGalleryViewState extends State { final _scrollController = ScrollController(); bool _showFavoritesOnly = false; + // 离线缓存浏览模式:仅展示已缓存的壁纸,不发起网络请求 + bool _isOfflineMode = false; + // 修复6.1: 错误提示状态 String? _errorMessage; @@ -193,11 +197,21 @@ class _WallpaperGalleryViewState extends State { } // 修复7: 从结果中提取去重后的新item + // 修复11: 对不支持分类筛选的源返回的结果,按 category 进行客户端二次过滤 List _extractNewItems(List results) { final newItems = []; + final selectedCategoryId = _category.id; + for (final result in results) { for (final item in result.items) { if (!_loadedIds.contains(item.id)) { + // 客户端二次过滤:用户选择了特定分类时,过滤掉不匹配的item + // 仅当 item 有 primaryCategory 时才过滤(某些源不返回分类信息) + if (selectedCategoryId != 'all' && + item.primaryCategory.isNotEmpty && + !_categoryMatches(item.primaryCategory, selectedCategoryId)) { + continue; + } newItems.add(item); _loadedIds.add(item.id); } @@ -206,6 +220,16 @@ class _WallpaperGalleryViewState extends State { return newItems; } + /// 检查 item 的分类是否匹配用户选择的分类 + /// 支持模糊匹配(如 "nature" 匹配 "Nature" / "自然" 等) + bool _categoryMatches(String itemCategory, String selectedCategoryId) { + final itemLower = itemCategory.toLowerCase(); + final selectedLower = selectedCategoryId.toLowerCase(); + return itemLower == selectedLower || + itemLower.contains(selectedLower) || + selectedLower.contains(itemLower); + } + // 修复7: 检查是否有更多页 bool _hasMorePages(List results) { final anyHasNext = results.any((r) => r.hasNext); @@ -400,10 +424,11 @@ class _WallpaperGalleryViewState extends State { } void _onSourceChanged(WallpaperSource? source) { - if (_source == source && !_showFavoritesOnly) return; + if (_source == source && !_showFavoritesOnly && !_isOfflineMode) return; setState(() { _source = source; _showFavoritesOnly = false; + _isOfflineMode = false; _selectedId = null; }); _loadWallpapers(reset: true); @@ -414,6 +439,7 @@ class _WallpaperGalleryViewState extends State { if (_showFavoritesOnly) return; setState(() { _showFavoritesOnly = true; + _isOfflineMode = false; _source = null; _selectedId = null; _isLoading = true; @@ -421,6 +447,37 @@ class _WallpaperGalleryViewState extends State { _loadFavorites(); } + /// 切换离线缓存浏览模式 + /// 开启时仅展示_cachedUrls中包含的壁纸项,不发起网络请求 + /// 关闭时恢复在线模式 + void _onOfflineTap() { + setState(() { + _isOfflineMode = !_isOfflineMode; + if (_isOfflineMode) { + _showFavoritesOnly = false; + _selectedId = null; + } + }); + } + + /// 离线模式下展示的壁纸列表:过滤出已缓存的壁纸项 + List get _displayItems { + if (!_isOfflineMode) return _items; + return _items.where((item) { + final url = WallpaperMasonryGrid.resolveImageUrl(item); + return url.isNotEmpty && _cachedUrls.contains(url); + }).toList(); + } + + /// 已缓存壁纸数量(用于源栏chip显示) + int get _cachedItemCount { + if (_cachedUrls.isEmpty) return 0; + return _items.where((item) { + final url = WallpaperMasonryGrid.resolveImageUrl(item); + return url.isNotEmpty && _cachedUrls.contains(url); + }).length; + } + /// 加载收藏列表 void _loadFavorites() { final favs = WallpaperFavoriteService.favorites; @@ -451,6 +508,7 @@ class _WallpaperGalleryViewState extends State { _category = category; _selectedId = null; _showFavoritesOnly = false; + _isOfflineMode = false; }); _loadWallpapers(reset: true); } @@ -460,12 +518,15 @@ class _WallpaperGalleryViewState extends State { _searchQuery = query; _selectedId = null; _showFavoritesOnly = false; + _isOfflineMode = false; }); _loadWallpapers(reset: true); } void _onLoadMore() { - if (_isLoading || !_hasMore || _showFavoritesOnly) return; + if (_isLoading || !_hasMore || _showFavoritesOnly || _isOfflineMode) { + return; + } _currentPage++; _loadWallpapers(); } @@ -521,16 +582,21 @@ class _WallpaperGalleryViewState extends State { showSpeed: !isDrawer, isFavoriteMode: _showFavoritesOnly, onFavoriteTap: _onFavoriteTap, + isOfflineMode: _isOfflineMode, + onOfflineTap: _onOfflineTap, + cachedCount: _cachedItemCount, ), - const SizedBox(height: 4), - WallpaperCategoryBar( - currentCategory: _category, - onCategoryChanged: _onCategoryChanged, - ), - WallpaperSearchBar( - controller: _searchController, - onSubmitted: _onSearch, - ), + if (!_isOfflineMode) ...[ + const SizedBox(height: 4), + WallpaperCategoryBar( + currentCategory: _category, + onCategoryChanged: _onCategoryChanged, + ), + WallpaperSearchBar( + controller: _searchController, + onSubmitted: _onSearch, + ), + ], Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), @@ -552,6 +618,24 @@ class _WallpaperGalleryViewState extends State { // 修复6.1: 构建内容区域(含错误提示和重试按钮) Widget _buildContent(AppThemeExtension ext, bool isDrawer) { + // 离线模式:仅展示已缓存壁纸,无网络请求 + if (_isOfflineMode) { + if (_displayItems.isEmpty) { + return _buildOfflineEmptyState(ext); + } + return WallpaperMasonryGrid( + items: _displayItems, + isLoading: false, + hasMore: false, + selectedId: _selectedId, + onSelect: _onSelect, + onLoadMore: _onLoadMore, + onItemTap: _onItemTap, + onImageLoaded: _onImageLoaded, + crossAxisCount: isDrawer ? 3 : 2, + ); + } + if (_items.isEmpty && !_isLoading && _errorMessage != null) { return Center( child: Column( @@ -601,6 +685,59 @@ class _WallpaperGalleryViewState extends State { ); } + /// 离线模式空状态:无缓存壁纸时的友好提示 + Widget _buildOfflineEmptyState(AppThemeExtension ext) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.cloud_download, + size: 56, + color: ext.textHint, + ), + const SizedBox(height: 16), + Text( + '暂无缓存壁纸', + style: TextStyle( + color: ext.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + '联网浏览后会自动缓存\n下次可在离线模式下查看', + style: TextStyle( + color: ext.textSecondary, + fontSize: 14, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10), + borderRadius: BorderRadius.circular(20), + color: ext.accent, + onPressed: _onOfflineTap, + child: Text( + '返回在线浏览', + style: TextStyle( + color: ext.textOnAccent, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } + Widget _buildHandle(AppThemeExtension ext) { return Center( child: Container( @@ -620,10 +757,16 @@ class _WallpaperGalleryViewState extends State { padding: const EdgeInsets.fromLTRB(AppSpacing.md, 4, AppSpacing.md, 8), child: Row( children: [ - Icon(CupertinoIcons.photo_on_rectangle, size: 20, color: ext.accent), + Icon( + _isOfflineMode + ? CupertinoIcons.square_stack_3d_down_right + : CupertinoIcons.photo_on_rectangle, + size: 20, + color: ext.accent, + ), const SizedBox(width: 6), Text( - '在线壁纸', + _isOfflineMode ? '已缓存壁纸' : '在线壁纸', style: TextStyle( color: ext.textPrimary, fontSize: 18, diff --git a/lib/features/template/wallpaper_gallery/wallpaper_source_bar.dart b/lib/features/template/wallpaper_gallery/wallpaper_source_bar.dart index fdf38565..3b5d1508 100644 --- a/lib/features/template/wallpaper_gallery/wallpaper_source_bar.dart +++ b/lib/features/template/wallpaper_gallery/wallpaper_source_bar.dart @@ -1,9 +1,10 @@ /// ============================================================ /// 闲言APP — 壁纸源切换横滑组件 /// 创建时间: 2026-05-04 -/// 更新时间: 2026-06-12 +/// 更新时间: 2026-06-19 /// 作用: 横向滑动切换壁纸来源,显示源名称+速度标签+健康状态 -/// 上次更新: 从shared/widgets迁移至features/template模块,简化template导入路径 +/// + 收藏/全部/已缓存(离线模式) 三个快捷入口 +/// 上次更新: 新增"已缓存"离线模式切换chip,支持离线浏览已缓存壁纸 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -24,6 +25,9 @@ class WallpaperSourceBar extends StatelessWidget { this.showSpeed = true, this.isFavoriteMode = false, this.onFavoriteTap, + this.isOfflineMode = false, + this.onOfflineTap, + this.cachedCount = 0, }); final WallpaperSource? currentSource; @@ -32,10 +36,21 @@ class WallpaperSourceBar extends StatelessWidget { final bool isFavoriteMode; final VoidCallback? onFavoriteTap; + /// 是否处于离线缓存模式 + final bool isOfflineMode; + + /// 离线模式切换回调 + final VoidCallback? onOfflineTap; + + /// 已缓存壁纸数量(用于在chip上显示) + final int cachedCount; + @override Widget build(BuildContext context) { final ext = AppTheme.ext(context); - final isAll = currentSource == null && !isFavoriteMode; + final isAll = currentSource == null && + !isFavoriteMode && + !isOfflineMode; final healthMap = WallpaperHealthService.healthMap; return SizedBox( @@ -43,7 +58,7 @@ class WallpaperSourceBar extends StatelessWidget { child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), - itemCount: WallpaperSource.values.length + 2, + itemCount: WallpaperSource.values.length + 3, separatorBuilder: (_, __) => const SizedBox(width: 6), itemBuilder: (_, index) { if (index == 0) { @@ -68,8 +83,21 @@ class WallpaperSourceBar extends StatelessWidget { onTap: () => onSourceChanged(null), ); } - final source = WallpaperSource.values[index - 2]; - final isActive = source == currentSource && !isFavoriteMode; + if (index == 2) { + return _SourceChip( + label: cachedCount > 0 ? '💾 已缓存 $cachedCount' : '💾 已缓存', + isActive: isOfflineMode, + ext: ext, + showSpeed: false, + speedText: '', + healthStatus: _HealthStatus.normal, + onTap: () => onOfflineTap?.call(), + ); + } + final source = WallpaperSource.values[index - 3]; + final isActive = source == currentSource && + !isFavoriteMode && + !isOfflineMode; final health = healthMap[source]; final healthStatus = _resolveHealthStatus(source, health); diff --git a/lib/features/tool_center/leisure/models/leisure_card.dart b/lib/features/tool_center/leisure/models/leisure_card.dart index 5b342cd5..19175b0e 100644 --- a/lib/features/tool_center/leisure/models/leisure_card.dart +++ b/lib/features/tool_center/leisure/models/leisure_card.dart @@ -1,12 +1,13 @@ /// ============================================================ /// 闲言APP — 闲情逸致卡片模型 /// 创建时间: 2026-05-27 -/// 更新时间: 2026-06-17 +/// 更新时间: 2026-06-19 /// 作用: 时间线卡片数据模型 — 吃/玩卡片 -/// 上次更新: 价格枚举扩展为平价/中等/高档三档,label改为key供翻译系统使用 +/// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0 /// ============================================================ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; part 'leisure_card.freezed.dart'; @@ -129,7 +130,7 @@ extension LeisureCardX on LeisureCard { description: json['description'] as String? ?? '', location: json['location'] as String? ?? '', province: json['province'] as String? ?? '', - altitude: json['altitude'] as int?, + altitude: json['altitude'] == null ? null : SafeJson.parseInt(json['altitude']), priceType: LeisurePriceType.fromIdCompat( json['priceType'] as String? ?? 'unknown', ), @@ -138,6 +139,6 @@ extension LeisureCardX on LeisureCard { riskWarnings: (json['riskWarnings'] as List?)?.cast() ?? [], isFood: json['isFood'] as bool? ?? false, - heatLevel: json['heatLevel'] as int? ?? 1, + heatLevel: SafeJson.parseInt(json['heatLevel'], 1), ); } diff --git a/lib/features/tool_center/leisure/presentation/pages/leisure_timeline_page.dart b/lib/features/tool_center/leisure/presentation/pages/leisure_timeline_page.dart index 7615dd1b..4476260e 100644 --- a/lib/features/tool_center/leisure/presentation/pages/leisure_timeline_page.dart +++ b/lib/features/tool_center/leisure/presentation/pages/leisure_timeline_page.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 闲情逸致时间线主页面 /// 创建时间: 2026-05-27 -/// 更新时间: 2026-05-28 +/// 更新时间: 2026-06-19 /// 作用: 时间线布局 — 中轴线+左吃右玩+今日分割线+自动定位+粘性月份头+日历切换 -/// 上次更新: 月份吸顶改用AppStickyHeaderSliver+日历跳转添加延迟重试 +/// 上次更新: 修复 _scrollToToday/_scrollToDate 异步回调中使用 ref/setState 未检查 mounted 导致卸载后报错 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -53,6 +53,7 @@ class _LeisureTimelinePageState extends ConsumerState { void _scrollToToday() { Future.delayed(const Duration(milliseconds: 500), () { + if (!mounted) return; final state = ref.read(leisureTimelineProvider); final todayIndex = state.todayIndex; if (todayIndex >= 0 && @@ -63,9 +64,10 @@ class _LeisureTimelinePageState extends ConsumerState { } void _scrollToDate(String date) { + if (!mounted) return; setState(() => _showCalendar = false); Future.delayed(const Duration(milliseconds: 300), () { - _tryScrollToDate(date); + if (mounted) _tryScrollToDate(date); }); } diff --git a/lib/features/tool_center/statistics/presentation/statistics_page.dart b/lib/features/tool_center/statistics/presentation/statistics_page.dart index 13d90f3f..5d31a317 100644 --- a/lib/features/tool_center/statistics/presentation/statistics_page.dart +++ b/lib/features/tool_center/statistics/presentation/statistics_page.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 统计页面 (增强版) // 创建时间: 2026-04-28 -// 更新时间: 2026-06-05 +// 更新时间: 2026-06-19 // 作用: 站点统计+用户数据+签到趋势+金币分布+内容饼图+骨架屏 -// 上次更新: DeferredBuilder替换为SafeChartWidget,统一图表生命周期保护 +// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0 // ============================================================ import 'dart:math'; @@ -13,6 +13,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shimmer/shimmer.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../../../app/providers/connectivity_provider.dart'; import '../../../../core/theme/app_radius.dart'; @@ -1052,8 +1053,8 @@ class _SigninTrendChart extends StatelessWidget { Widget build(BuildContext context) { final rawTrend = signinStats?['trend_7days']; final trendData = _buildTrendData(rawTrend); - final longestStreak = signinStats?['longest_streak'] as int? ?? 0; - final todayCoins = signinStats?['today_coins'] as int? ?? 0; + final longestStreak = SafeJson.parseInt(signinStats?['longest_streak']); + final todayCoins = SafeJson.parseInt(signinStats?['today_coins']); return Padding( padding: const EdgeInsets.fromLTRB( @@ -1244,10 +1245,10 @@ class _CoinBarChart extends StatelessWidget { @override Widget build(BuildContext context) { - final totalLogs = coinStats?['total_logs'] as int? ?? 0; - final totalPoints = coinStats?['total_points'] as int? ?? 0; - final todayLogs = coinStats?['today_logs'] as int? ?? 0; - final todayPoints = coinStats?['today_points'] as int? ?? 0; + final totalLogs = SafeJson.parseInt(coinStats?['total_logs']); + final totalPoints = SafeJson.parseInt(coinStats?['total_points']); + final todayLogs = SafeJson.parseInt(coinStats?['today_logs']); + final todayPoints = SafeJson.parseInt(coinStats?['today_points']); final bars = [ _CoinBar('总记录', totalLogs.toDouble(), const Color(0xFF6C63FF)), @@ -1355,9 +1356,9 @@ class _ContentPieChart extends StatelessWidget { @override Widget build(BuildContext context) { - final totalNotes = noteStats?['total'] as int? ?? 0; - final normalNotes = noteStats?['normal'] as int? ?? 0; - final todayNotes = noteStats?['today'] as int? ?? 0; + final totalNotes = SafeJson.parseInt(noteStats?['total']); + final normalNotes = SafeJson.parseInt(noteStats?['normal']); + final todayNotes = SafeJson.parseInt(noteStats?['today']); final otherNotes = (totalNotes - normalNotes - todayNotes) .clamp(0, totalNotes) .toDouble(); @@ -1458,11 +1459,11 @@ class _UserGrowthChart extends StatelessWidget { @override Widget build(BuildContext context) { - final totalUsers = userStats?['total_users'] as int? ?? 0; - final activeUsers = userStats?['active_users'] as int? ?? 0; - final todayNew = userStats?['today_new'] as int? ?? 0; - final weekNew = userStats?['week_new'] as int? ?? 0; - final monthNew = userStats?['month_new'] as int? ?? 0; + final totalUsers = SafeJson.parseInt(userStats?['total_users']); + final activeUsers = SafeJson.parseInt(userStats?['active_users']); + final todayNew = SafeJson.parseInt(userStats?['today_new']); + final weekNew = SafeJson.parseInt(userStats?['week_new']); + final monthNew = SafeJson.parseInt(userStats?['month_new']); return Padding( padding: const EdgeInsets.fromLTRB( diff --git a/lib/features/tool_center/statistics/providers/user_stats_provider.dart b/lib/features/tool_center/statistics/providers/user_stats_provider.dart index 9828fe1a..34ef5576 100644 --- a/lib/features/tool_center/statistics/providers/user_stats_provider.dart +++ b/lib/features/tool_center/statistics/providers/user_stats_provider.dart @@ -1,14 +1,15 @@ /// ============================================================ /// 闲言APP — 用户数据统计状态管理 /// 创建时间: 2026-05-14 -/// 更新时间: 2026-05-14 +/// 更新时间: 2026-06-19 /// 作用: 学习/积分/收藏统计数据 + 热力图 + 趋势图 -/// 上次更新: 初始创建 +/// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0 /// ============================================================ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../../../core/network/api_client.dart'; import '../../../../app/providers/connectivity_provider.dart'; @@ -45,15 +46,15 @@ class LearningOverview { factory LearningOverview.fromJson(Map json) { return LearningOverview( - totalViews: json['total_views'] as int? ?? 0, - totalInteractions: json['total_interactions'] as int? ?? 0, - totalFavorites: json['total_favorites'] as int? ?? 0, - weekViews: json['week_views'] as int? ?? 0, - weekInteractions: json['week_interactions'] as int? ?? 0, - weekFavorites: json['week_favorites'] as int? ?? 0, - todayViews: json['today_views'] as int? ?? 0, - todayInteractions: json['today_interactions'] as int? ?? 0, - todayFavorites: json['today_favorites'] as int? ?? 0, + totalViews: SafeJson.parseInt(json['total_views']), + totalInteractions: SafeJson.parseInt(json['total_interactions']), + totalFavorites: SafeJson.parseInt(json['total_favorites']), + weekViews: SafeJson.parseInt(json['week_views']), + weekInteractions: SafeJson.parseInt(json['week_interactions']), + weekFavorites: SafeJson.parseInt(json['week_favorites']), + todayViews: SafeJson.parseInt(json['today_views']), + todayInteractions: SafeJson.parseInt(json['today_interactions']), + todayFavorites: SafeJson.parseInt(json['today_favorites']), ); } diff --git a/lib/features/user_center/models/user_center_models.dart b/lib/features/user_center/models/user_center_models.dart index f65412f4..0fe28cf3 100644 --- a/lib/features/user_center/models/user_center_models.dart +++ b/lib/features/user_center/models/user_center_models.dart @@ -1,11 +1,13 @@ /// ============================================================ /// 闲言APP — 用户中心数据模型 /// 创建时间: 2026-04-29 -/// 更新时间: 2026-05-14 +/// 更新时间: 2026-06-19 /// 作用: 用户中心相关数据模型(公开主页/互动记录/热力图/面板等) -/// 上次更新: v11.0.0 DashboardModel/PublicProfileModel新增level/exp/expToNext/expProgress/levelTitle字段 +/// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0 /// ============================================================ +import 'package:xianyan/core/utils/safe_json.dart'; + class PublicProfileModel { const PublicProfileModel({ required this.id, @@ -103,10 +105,10 @@ class InteractionRecord { factory InteractionRecord.fromJson(Map json) { return InteractionRecord( - id: json['id'] as int? ?? 0, - userId: json['user_id'] as int? ?? 0, + id: SafeJson.parseInt(json['id']), + userId: SafeJson.parseInt(json['user_id']), feedType: json['feed_type'] as String? ?? '', - feedId: json['feed_id'] as int? ?? 0, + feedId: SafeJson.parseInt(json['feed_id']), action: json['action'] as String? ?? '', extra: json['extra'] as String? ?? '', createtime: json['createtime']?.toString() ?? '', @@ -126,7 +128,7 @@ class HeatmapEntry { factory HeatmapEntry.fromJson(Map json) { return HeatmapEntry( date: json['date'] as String? ?? '', - count: json['count'] as int? ?? 0, + count: SafeJson.parseInt(json['count']), ); } } diff --git a/lib/features/user_center/presentation/learning_center_page.dart b/lib/features/user_center/presentation/learning_center_page.dart index 960ba3ff..25ca75eb 100644 --- a/lib/features/user_center/presentation/learning_center_page.dart +++ b/lib/features/user_center/presentation/learning_center_page.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 学习中心页面 (增强版 · 双Tab合并) // 创建时间: 2026-04-29 -// 更新时间: 2026-06-05 +// 更新时间: 2026-06-19 // 作用: 个人学习中心+学习进度双Tab,仪表盘+每日推荐+热力图+统计+趋势图+目标进度 -// 上次更新: DeferredBuilder替换为SafeChartWidget,统一图表生命周期保护 +// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0 // ============================================================ import 'dart:math'; @@ -13,6 +13,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:xianyan/core/router/app_nav_extension.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import 'package:shimmer/shimmer.dart'; import 'package:syncfusion_flutter_charts/charts.dart'; @@ -481,9 +482,9 @@ class _LearningCenterPageState extends ConsumerState { LearningProgressState state, ) { final overview = state.overviewData; - final totalViews = overview?['total_view_count'] as int? ?? 0; - final totalFavorites = overview?['total_favorite_count'] as int? ?? 0; - final streakDays = overview?['streak_days'] as int? ?? 0; + final totalViews = SafeJson.parseInt(overview?['total_view_count']); + final totalFavorites = SafeJson.parseInt(overview?['total_favorite_count']); + final streakDays = SafeJson.parseInt(overview?['streak_days']); return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, @@ -1467,7 +1468,7 @@ class _DailyRecommendCards extends StatelessWidget { ? () => _navigateToDetail( context, 'poetry', - poetry['id'] as int?, + poetry['id'] == null ? null : SafeJson.parseInt(poetry['id']), ) : null, ), @@ -1482,7 +1483,7 @@ class _DailyRecommendCards extends StatelessWidget { ? () => _navigateToDetail( context, 'chengyu', - chengyu['id'] as int?, + chengyu['id'] == null ? null : SafeJson.parseInt(chengyu['id']), ) : null, ), @@ -1497,7 +1498,7 @@ class _DailyRecommendCards extends StatelessWidget { ? () => _navigateToDetail( context, 'wisdom', - wisdom['id'] as int?, + wisdom['id'] == null ? null : SafeJson.parseInt(wisdom['id']), ) : null, ), @@ -1512,7 +1513,7 @@ class _DailyRecommendCards extends StatelessWidget { ? () => _navigateToDetail( context, 'story', - story['id'] as int?, + story['id'] == null ? null : SafeJson.parseInt(story['id']), ) : null, ), diff --git a/lib/features/user_center/presentation/learning_progress_page.dart b/lib/features/user_center/presentation/learning_progress_page.dart index 80b4f370..e604398b 100644 --- a/lib/features/user_center/presentation/learning_progress_page.dart +++ b/lib/features/user_center/presentation/learning_progress_page.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 学习进度可视化页面 /// 创建时间: 2026-05-10 -/// 更新时间: 2026-06-05 +/// 更新时间: 2026-06-19 /// 作用: 环形进度图 + 趋势折线图 + 分类柱状图 + 目标设置 -/// 上次更新: DeferredBuilder替换为SafeChartWidget,统一图表生命周期保护 +/// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -11,6 +11,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:syncfusion_flutter_charts/charts.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../../core/theme/app_radius.dart'; import '../../../core/theme/app_spacing.dart'; @@ -270,9 +271,9 @@ class _LearningProgressPageState extends ConsumerState { Widget _buildStatRow(AppThemeExtension ext, LearningProgressState state) { final overview = state.overviewData; - final totalViews = overview?['total_view_count'] as int? ?? 0; - final totalFavorites = overview?['total_favorite_count'] as int? ?? 0; - final streakDays = overview?['streak_days'] as int? ?? 0; + final totalViews = SafeJson.parseInt(overview?['total_view_count']); + final totalFavorites = SafeJson.parseInt(overview?['total_favorite_count']); + final streakDays = SafeJson.parseInt(overview?['streak_days']); return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, diff --git a/lib/features/user_center/presentation/public_profile_page.dart b/lib/features/user_center/presentation/public_profile_page.dart index 8b3b964d..931d9ef3 100644 --- a/lib/features/user_center/presentation/public_profile_page.dart +++ b/lib/features/user_center/presentation/public_profile_page.dart @@ -1,15 +1,16 @@ // ============================================================ // 闲言APP — 公开用户主页 // 创建时间: 2026-04-29 -// 更新时间: 2026-06-06 +// 更新时间: 2026-06-19 // 作用: 用户公开主页+视差头部+互动按钮+成就展示+文章预览 -// 上次更新: 硬编码中文替换为翻译键 +// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0 // ============================================================ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../../core/theme/app_radius.dart'; import '../../../core/theme/app_spacing.dart'; @@ -676,7 +677,7 @@ class _TitleBadgeSection extends StatelessWidget { @override Widget build(BuildContext context) { final title = profile['title'] as String? ?? ''; - final score = profile['score'] as int? ?? 0; + final score = SafeJson.parseInt(profile['score']); return Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md) diff --git a/lib/features/user_center/providers/coin_provider.dart b/lib/features/user_center/providers/coin_provider.dart index 5d14435c..23b45311 100644 --- a/lib/features/user_center/providers/coin_provider.dart +++ b/lib/features/user_center/providers/coin_provider.dart @@ -1,12 +1,13 @@ /// ============================================================ /// 闲言APP — 金币记录状态管理 /// 创建时间: 2026-04-29 -/// 更新时间: 2026-04-29 +/// 更新时间: 2026-06-19 /// 作用: 金币流水查询功能状态管理 -/// 上次更新: 初始创建 +/// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0 /// ============================================================ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../../core/utils/logger.dart'; import '../services/user_center_service.dart'; @@ -97,7 +98,7 @@ class CoinNotifier extends Notifier { try { final result = await UserCenterService.getCoinLog(page: page); - final total = result['total'] as int? ?? 0; + final total = SafeJson.parseInt(result['total']); final list = (result['list'] as List? ?? []) .map((e) => CoinLogItem.fromJson(e as Map)) .toList(); diff --git a/lib/features/user_center/providers/interaction_provider.dart b/lib/features/user_center/providers/interaction_provider.dart index b8bec720..a8849521 100644 --- a/lib/features/user_center/providers/interaction_provider.dart +++ b/lib/features/user_center/providers/interaction_provider.dart @@ -1,14 +1,15 @@ /// ============================================================ /// 闲言APP — 通用互动状态管理 /// 创建时间: 2026-04-29 -/// 更新时间: 2026-05-10 +/// 更新时间: 2026-06-19 /// 作用: 通用互动系统状态管理(18种操作)+ 离线浏览记录缓存 + 智能推荐过滤 -/// 上次更新: 新增dislikeContent/blockContent/getDislikedIds/getBlockedIds方法 +/// 上次更新: 类型安全修复(int vs num): target_id 使用 SafeJson.parseInt /// ============================================================ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../../core/services/network/connectivity_service.dart'; import '../../../core/storage/kv_storage.dart'; @@ -144,7 +145,7 @@ class InteractionNotifier extends Notifier { try { await UserCenterService.interaction( action: InteractionAction.view, - targetId: item['target_id'] as int, + targetId: SafeJson.parseInt(item['target_id']), targetType: item['target_type'] as String, ); synced++; @@ -258,7 +259,7 @@ class InteractionNotifier extends Notifier { filterType: filterType, page: page, ); - final total = result['total'] as int? ?? 0; + final total = SafeJson.parseInt(result['total']); final list = (result['list'] as List? ?? []) .map((e) => e as Map) .toList(); diff --git a/lib/features/user_center/services/user_center_service.dart b/lib/features/user_center/services/user_center_service.dart index 381556db..6273b054 100644 --- a/lib/features/user_center/services/user_center_service.dart +++ b/lib/features/user_center/services/user_center_service.dart @@ -1,12 +1,13 @@ /// ============================================================ /// 闲言APP — 用户中心服务 /// 创建时间: 2026-04-29 -/// 更新时间: 2026-05-27 +/// 更新时间: 2026-06-19 /// 作用: 用户中心相关API封装(信息/签到/收藏/笔记/点赞/互动/面板/金币/主页/统计/设备管理) -/// 上次更新: v6.5.57 新增getSigninConfig获取补签配置接口 +/// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0 /// ============================================================ import 'package:dio/dio.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../../core/network/api_client.dart'; import '../../../core/network/api_exception.dart'; @@ -178,7 +179,7 @@ class UserCenterService { '$_basePath/signin', ); final data = response.data as Map; - final code = data['code'] as int? ?? 0; + final code = SafeJson.parseInt(data['code']); if (code == 1) { Log.i('签到成功'); return data['data'] as Map? ?? {}; @@ -286,7 +287,7 @@ class UserCenterService { data: data, ); final respData = response.data as Map; - final code = respData['code'] as int? ?? 0; + final code = SafeJson.parseInt(respData['code']); if (code != 1) { throw ApiException( code: code, @@ -340,7 +341,7 @@ class UserCenterService { }, ); final respData = response.data as Map; - final code = respData['code'] as int? ?? 0; + final code = SafeJson.parseInt(respData['code']); if (code != 1) { throw ApiException( code: code, @@ -355,7 +356,7 @@ class UserCenterService { data: data, ); final respData = response.data as Map; - final code = respData['code'] as int? ?? 0; + final code = SafeJson.parseInt(respData['code']); if (code != 1) { throw ApiException( code: code, @@ -388,7 +389,7 @@ class UserCenterService { }, ); final respData = response.data as Map; - final code = respData['code'] as int? ?? 0; + final code = SafeJson.parseInt(respData['code']); if (code != 1) { throw ApiException( code: code, @@ -436,7 +437,7 @@ class UserCenterService { queryParameters: queryParams, ); final respData = response.data as Map; - final code = respData['code'] as int? ?? 0; + final code = SafeJson.parseInt(respData['code']); if (code != 1) { throw ApiException( code: code, @@ -451,7 +452,7 @@ class UserCenterService { data: data, ); final respData = response.data as Map; - final code = respData['code'] as int? ?? 0; + final code = SafeJson.parseInt(respData['code']); if (code != 1) { throw ApiException( code: code, diff --git a/lib/features/weather/presentation/weather_settings_page.dart b/lib/features/weather/presentation/weather_settings_page.dart index 3262d6b7..f5884363 100644 --- a/lib/features/weather/presentation/weather_settings_page.dart +++ b/lib/features/weather/presentation/weather_settings_page.dart @@ -1,14 +1,15 @@ /// ============================================================ /// 闲言APP — 天气诗词设置页面 /// 创建时间: 2026-05-19 -/// 更新时间: 2026-05-19 +/// 更新时间: 2026-06-19 /// 作用: 天气诗词设置 -/// 上次更新: 改用Notifier模式替代StateProvider,修复所有编译错误 +/// 上次更新: 类型安全修复(int vs num): poemCount 使用 SafeJson.parseInt /// ============================================================ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart' show Divider; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../../core/theme/app_radius.dart'; import '../../../core/theme/app_spacing.dart'; @@ -202,7 +203,7 @@ class _WeatherSettingsPageState extends ConsumerState { iconColor: const Color(0xFF5856D6), title: '推荐诗词数量', options: const ['3', '5', '7'], - selectedKey: (settings['poemCount'] as int).toString(), + selectedKey: SafeJson.parseInt(settings['poemCount']).toString(), onSelected: (key) => ref .read(weatherSettingsProvider.notifier) .set('poemCount', int.parse(key)), diff --git a/lib/features/weather/weather_provider.dart b/lib/features/weather/weather_provider.dart index d59caa90..d301c463 100644 --- a/lib/features/weather/weather_provider.dart +++ b/lib/features/weather/weather_provider.dart @@ -1,14 +1,15 @@ /// ============================================================ /// 闲言APP — 天气状态管理 /// 创建时间: 2026-05-02 -/// 更新时间: 2026-06-12 +/// 更新时间: 2026-06-19 /// 作用: 天气数据 + 天气-诗词关联推荐状态 -/// 上次更新: 从 providers/ 子目录移至 weather 模块根目录 +/// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? /// ============================================================ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../../core/storage/kv_storage.dart'; import '../../../core/utils/logger.dart'; @@ -61,7 +62,7 @@ class WeatherHistoryDay { factory WeatherHistoryDay.fromJson(Map json) { final weatherJson = json['weather'] as Map?; - final moodIndex = json['moodIndex'] as int?; + final moodIndex = json['moodIndex'] == null ? null : SafeJson.parseInt(json['moodIndex']); final safeMoodIndex = moodIndex?.clamp(0, WeatherPoetryMood.values.length - 1); return WeatherHistoryDay( date: json['date'] as String? ?? '', @@ -155,7 +156,7 @@ class WeatherNotifier extends Notifier { if (raw == null || raw.isEmpty) return null; final json = jsonDecode(raw) as Map; final weatherJson = json['weather'] as Map?; - final moodIndex = json['moodIndex'] as int?; + final moodIndex = json['moodIndex'] == null ? null : SafeJson.parseInt(json['moodIndex']); final matchedPoems = (json['matchedPoems'] as List?) ?.map((e) => Map.from(e as Map)) .toList() ?? diff --git a/lib/features/widget/widget_provider.dart b/lib/features/widget/widget_provider.dart index c26fe787..ed17dc76 100644 --- a/lib/features/widget/widget_provider.dart +++ b/lib/features/widget/widget_provider.dart @@ -47,6 +47,14 @@ class WidgetNotifier extends Notifier { WidgetState build() => const WidgetState(); Future loadInstalledWidgets() async { + // [PlatformCapabilities] 统一能力查询: homeWidget + // macOS/Windows/Linux/Web 端 home_widget 插件无原生实现,调用会抛 MissingPluginException + if (pu.isOhos || !PlatformCapabilities.supports(CapabilityKey.homeWidget)) { + Log.w('WidgetNotifier: 当前平台不支持桌面小部件,跳过加载'); + state = state.copyWith(isLoading: false); + return; + } + state = state.copyWith(isLoading: true); try { final widgets = await HomeWidget.getInstalledWidgets(); diff --git a/lib/l10n/languages/ar.dart b/lib/l10n/languages/ar.dart index 54b050f4..0042c3c1 100644 --- a/lib/l10n/languages/ar.dart +++ b/lib/l10n/languages/ar.dart @@ -872,6 +872,8 @@ const ar = T( splitViewRatioTitle: 'نسبة العرض المقسم', splitViewEnabled: 'العرض المقسم', splitViewEnabledSubtitle: 'تفعيل العرض المقسم على الشاشة العريضة', + workbenchEnabled: 'وضع العمل', + workbenchEnabledSubtitle: 'تفعيل تخطيط العمل ثلاثي الأعمدة بأسلوب WeChat PC على الشاشات العريضة (تنقل+قائمة+تفاصيل)', shaderBackground: 'خلفية التظليل', shaderBackgroundSubtitle: 'تأثير التدرج السائل على بطاقات الاقتباسات', ), @@ -1391,7 +1393,7 @@ const ar = T( distMacOS: 'التوزيع عبر App Store', distHarmony: 'التوزيع عبر AppGallery', distWeb: 'يتطلب تقديم طلب', - distWindows: 'تم التنزيل من الموقع الرسمي لـ Xianyan', + distWindows: 'موزع بواسطة Microsoft Store', ), auth: TAuth( welcomeBack: 'مرحباً بعودتك', @@ -1676,6 +1678,7 @@ const ar = T( shaderBackground: 'خلفية متحركة', soundFeedback: 'رد صوتي', showOnNextLaunch: 'عرض المقدمة في التشغيل التالي', + knowNewFeatures: 'تعرف على ميزات V{0}', completeSetup: 'تم، دخول Xianyan', ), theme: TTheme( diff --git a/lib/l10n/languages/bn.dart b/lib/l10n/languages/bn.dart index f139eab7..81e8afa3 100644 --- a/lib/l10n/languages/bn.dart +++ b/lib/l10n/languages/bn.dart @@ -874,6 +874,8 @@ const bn = T( splitViewEnabled: 'স্প্লিট ভিউ', splitViewEnabledSubtitle: 'ওয়াইড স্ক্রিনে স্প্লিট ভিউ লেআউট সক্রিয় করুন', + workbenchEnabled: 'ওয়ার্কবেঞ্চ মোড', + workbenchEnabledSubtitle: 'ওয়াইড স্ক্রিনে WeChat PC-স্টাইল তিন-কলাম ওয়ার্কবেঞ্চ লেআউট সক্ষম করুন (নেভ+তালিকা+বিস্তারিত)', shaderBackground: 'শেডার ব্যাকগ্রাউন্ড', shaderBackgroundSubtitle: 'উদ্ধৃতি কার্ডে তরল গ্রেডিয়েন্ট প্রভাব', ), @@ -1400,7 +1402,7 @@ const bn = T( distMacOS: 'App Store এর মাধ্যমে বিতরণ', distHarmony: 'AppGallery এর মাধ্যমে বিতরণ', distWeb: 'আবেদন প্রয়োজন', - distWindows: 'Xianyan অফিসিয়াল ওয়েবসাইট থেকে ডাউনলোড', + distWindows: 'Microsoft Store দ্বারা বিতরণ করা হয়েছে', ), auth: TAuth( welcomeBack: 'ফিরে আসার জন্য স্বাগতম', @@ -1689,6 +1691,7 @@ const bn = T( shaderBackground: 'ইফেক্ট ব্যাকগ্রাউন্ড', soundFeedback: 'সাউন্ড ফিডব্যাক', showOnNextLaunch: 'পরবর্তী লঞ্চে ভূমিকা দেখান', + knowNewFeatures: 'V{0} বৈশিষ্ট্য সম্পর্কে জানুন', completeSetup: 'সম্পন্ন, Xianyan-এ প্রবেশ করুন', ), theme: TTheme( diff --git a/lib/l10n/languages/de.dart b/lib/l10n/languages/de.dart index 2509ffaa..00b46d7d 100644 --- a/lib/l10n/languages/de.dart +++ b/lib/l10n/languages/de.dart @@ -869,6 +869,8 @@ const de = T( splitViewRatioTitle: 'Geteilte Ansicht Verhältnis', splitViewEnabled: 'Geteilte Ansicht', splitViewEnabledSubtitle: 'Geteilte Ansicht auf Breitbild aktivieren', + workbenchEnabled: 'Workbench-Modus', + workbenchEnabledSubtitle: 'WeChat PC-Stil dreispaltige Workbench-Layout auf Breitbild aktivieren (Nav+Liste+Detail)', shaderBackground: 'Shader-Hintergrund', shaderBackgroundSubtitle: 'Fließender Farbverlauf auf Zitat-Karten', ), @@ -1400,7 +1402,7 @@ const de = T( distMacOS: 'Über den App Store vertrieben', distHarmony: 'Über AppGallery vertrieben', distWeb: 'Antrag erforderlich', - distWindows: 'Von der Xianyan-Website heruntergeladen', + distWindows: 'Vertrieben über Microsoft Store', ), auth: TAuth( welcomeBack: 'Willkommen zurück', @@ -1684,6 +1686,7 @@ const de = T( shaderBackground: 'Effekt-Hintergrund', soundFeedback: 'Ton-Feedback', showOnNextLaunch: 'Onboarding beim nächsten Start anzeigen', + knowNewFeatures: 'V{0} neue Funktionen entdecken', completeSetup: 'Fertig, Xianyan starten', ), theme: TTheme( diff --git a/lib/l10n/languages/en.dart b/lib/l10n/languages/en.dart index 69304626..6940ec28 100644 --- a/lib/l10n/languages/en.dart +++ b/lib/l10n/languages/en.dart @@ -881,6 +881,8 @@ const en = T( splitViewRatioTitle: 'Split View Ratio', splitViewEnabled: 'Split View', splitViewEnabledSubtitle: 'Enable split view layout on wide screens', + workbenchEnabled: 'Workbench Mode', + workbenchEnabledSubtitle: 'Enable WeChat PC-style three-column workbench layout on wide screens (nav+list+detail)', shaderBackground: 'Shader Background', shaderBackgroundSubtitle: 'Fluid gradient effect on quote cards', ), @@ -1410,7 +1412,7 @@ const en = T( distMacOS: 'Distributed via App Store', distHarmony: 'Distributed via AppGallery', distWeb: 'Application Required', - distWindows: 'Downloaded from Xianyan Official Website', + distWindows: 'Distributed by Microsoft Store', ), auth: TAuth( welcomeBack: 'Welcome Back', @@ -1701,6 +1703,7 @@ const en = T( shaderBackground: 'Effect Background', soundFeedback: 'Sound Feedback', showOnNextLaunch: 'Show onboarding next launch', + knowNewFeatures: 'Learn about V{0} features', completeSetup: 'Done, Enter Xianyan', ), theme: TTheme( diff --git a/lib/l10n/languages/es.dart b/lib/l10n/languages/es.dart index 9a70795b..71ec2cc7 100644 --- a/lib/l10n/languages/es.dart +++ b/lib/l10n/languages/es.dart @@ -879,6 +879,8 @@ const es = T( splitViewRatioTitle: 'Ratio de vista dividida', splitViewEnabled: 'Vista dividida', splitViewEnabledSubtitle: 'Activar vista dividida en pantalla ancha', + workbenchEnabled: 'Modo banco de trabajo', + workbenchEnabledSubtitle: 'Habilitar diseño de banco de trabajo de tres columnas estilo WeChat PC en pantalla ancha (nav+lista+detalle)', shaderBackground: 'Fondo shader', shaderBackgroundSubtitle: 'Efecto de degradado fluido en tarjetas de citas', ), @@ -1409,7 +1411,7 @@ const es = T( distMacOS: 'Distribuido a través de App Store', distHarmony: 'Distribuido a través de AppGallery', distWeb: 'Solicitud requerida', - distWindows: 'Descargado desde el sitio web oficial de Xianyan', + distWindows: 'Distribuido por Microsoft Store', ), auth: TAuth( welcomeBack: 'Bienvenido de nuevo', @@ -1696,6 +1698,7 @@ const es = T( shaderBackground: 'Fondo con efectos', soundFeedback: 'Respuesta sonora', showOnNextLaunch: 'Mostrar introducción en el próximo inicio', + knowNewFeatures: 'Conocer las funciones de V{0}', completeSetup: 'Listo, entrar a Xianyan', ), theme: TTheme( diff --git a/lib/l10n/languages/fr.dart b/lib/l10n/languages/fr.dart index 8ac0ca3b..612e5f52 100644 --- a/lib/l10n/languages/fr.dart +++ b/lib/l10n/languages/fr.dart @@ -874,6 +874,8 @@ const fr = T( splitViewRatioTitle: 'Ratio vue fractionnée', splitViewEnabled: 'Vue fractionnée', splitViewEnabledSubtitle: 'Activer la vue fractionnée en écran large', + workbenchEnabled: 'Mode atelier', + workbenchEnabledSubtitle: 'Activer la disposition atelier à trois colonnes style WeChat PC sur écran large (nav+liste+détail)', shaderBackground: 'Arrière-plan shader', shaderBackgroundSubtitle: 'Effet de dégradé fluide sur les cartes de citation', ), @@ -1415,7 +1417,7 @@ const fr = T( distMacOS: "Distribué via l'App Store", distHarmony: 'Distribué via AppGallery', distWeb: 'Demande requise', - distWindows: 'Téléchargé depuis le site officiel de Xianyan', + distWindows: 'Distribué par Microsoft Store', ), auth: TAuth( welcomeBack: 'Bon retour', @@ -1703,6 +1705,7 @@ const fr = T( shaderBackground: 'Fond animé', soundFeedback: 'Retour sonore', showOnNextLaunch: 'Afficher l\'intro au prochain lancement', + knowNewFeatures: 'Découvrir les fonctionnalités de V{0}', completeSetup: 'Terminé, entrer dans Xianyan', ), theme: TTheme( diff --git a/lib/l10n/languages/hi.dart b/lib/l10n/languages/hi.dart index 46e1db2a..c431b2c0 100644 --- a/lib/l10n/languages/hi.dart +++ b/lib/l10n/languages/hi.dart @@ -867,6 +867,8 @@ const hi = T( splitViewRatioTitle: 'स्प्लिट व्यू अनुपात', splitViewEnabled: 'स्प्लिट व्यू', splitViewEnabledSubtitle: 'वाइड स्क्रीन पर स्प्लिट व्यू लेआउट सक्षम करें', + workbenchEnabled: 'वर्कबेंच मोड', + workbenchEnabledSubtitle: 'वाइड स्क्रीन पर WeChat PC-शैली तीन-कॉलम वर्कबेंच लेआउट सक्षम करें (नेव+सूची+विवरण)', shaderBackground: 'शेडर पृष्ठभूमि', shaderBackgroundSubtitle: 'उद्धरण कार्ड पर तरल ग्रेडिएंट प्रभाव', ), @@ -1383,7 +1385,7 @@ const hi = T( distMacOS: 'App Store के माध्यम से वितरित', distHarmony: 'AppGallery के माध्यम से वितरित', distWeb: 'आवेदन आवश्यक', - distWindows: 'Xianyan आधिकारिक वेबसाइट से डाउनलोड', + distWindows: 'Microsoft Store द्वारा वितरित', ), auth: TAuth( welcomeBack: 'वापसी पर स्वागत', @@ -1665,6 +1667,7 @@ const hi = T( shaderBackground: 'प्रभाव पृष्ठभूमि', soundFeedback: 'ध्वनि प्रतिक्रिया', showOnNextLaunch: 'अगली बार परिचय दिखाएँ', + knowNewFeatures: 'V{0} सुविधाओं के बारे में जानें', completeSetup: 'पूर्ण, Xianyan में प्रवेश करें', ), theme: TTheme( diff --git a/lib/l10n/languages/it.dart b/lib/l10n/languages/it.dart index 3c9bc1da..ff04778b 100644 --- a/lib/l10n/languages/it.dart +++ b/lib/l10n/languages/it.dart @@ -878,6 +878,8 @@ const it = T( splitViewRatioTitle: 'Rapporto vista divisa', splitViewEnabled: 'Vista divisa', splitViewEnabledSubtitle: 'Abilita vista divisa su schermo largo', + workbenchEnabled: 'Modalità workbench', + workbenchEnabledSubtitle: 'Abilita layout workbench a tre colonne stile WeChat PC su schermo largo (nav+lista+dettaglio)', shaderBackground: 'Sfondo shader', shaderBackgroundSubtitle: 'Effetto gradiente fluido sulle carte delle citazioni', ), @@ -1409,7 +1411,7 @@ const it = T( distMacOS: 'Distribuito tramite App Store', distHarmony: 'Distribuito tramite AppGallery', distWeb: 'Richiesta necessaria', - distWindows: 'Scaricato dal sito ufficiale di Xianyan', + distWindows: 'Distribuito da Microsoft Store', ), auth: TAuth( welcomeBack: 'Bentornato', @@ -1694,6 +1696,7 @@ const it = T( shaderBackground: 'Sfondo effetti', soundFeedback: 'Feedback sonoro', showOnNextLaunch: 'Mostra onboarding al prossimo avvio', + knowNewFeatures: 'Scopri le funzionalità di V{0}', completeSetup: 'Fatto, entra in Xianyan', ), theme: TTheme( diff --git a/lib/l10n/languages/ja.dart b/lib/l10n/languages/ja.dart index f90943f5..cd4ec6fb 100644 --- a/lib/l10n/languages/ja.dart +++ b/lib/l10n/languages/ja.dart @@ -864,6 +864,8 @@ const ja = T( splitViewRatioTitle: '分割表示比率', splitViewEnabled: '分割表示', splitViewEnabledSubtitle: 'ワイドスクリーンで分割表示レイアウトを有効化', + workbenchEnabled: 'ワークベンチモード', + workbenchEnabledSubtitle: 'ワイド画面でWeChat PC風3カラムワークベンチレイアウトを有効化(ナビ+リスト+詳細)', shaderBackground: 'シェーダー背景', shaderBackgroundSubtitle: '名言カードの流体グラデーション効果', ), @@ -1361,7 +1363,7 @@ const ja = T( distMacOS: 'App Store経由で配布', distHarmony: 'AppGallery経由で配布', distWeb: '申請が必要', - distWindows: '閒言公式サイトからダウンロード', + distWindows: 'Microsoft Store から配信', ), auth: TAuth( welcomeBack: 'おかえりなさい', @@ -1631,6 +1633,7 @@ const ja = T( shaderBackground: 'エフェクト背景', soundFeedback: 'サウンドフィードバック', showOnNextLaunch: '次回起動時もガイドを表示', + knowNewFeatures: 'V{0} の新機能について', completeSetup: '設定完了、Xianyanを始める', ), theme: TTheme( diff --git a/lib/l10n/languages/ko.dart b/lib/l10n/languages/ko.dart index 1c562fca..23ff0853 100644 --- a/lib/l10n/languages/ko.dart +++ b/lib/l10n/languages/ko.dart @@ -864,6 +864,8 @@ const ko = T( splitViewRatioTitle: '분할 화면 비율', splitViewEnabled: '분할 화면', splitViewEnabledSubtitle: '와이드 스크린에서 분할 화면 레이아웃 활성화', + workbenchEnabled: '워크벤치 모드', + workbenchEnabledSubtitle: '와이드 스크린에서 WeChat PC 스타일 3단 워크벤치 레이아웃 활성화(네비+목록+상세)', shaderBackground: '셰이더 배경', shaderBackgroundSubtitle: '명언 카드 유체 그라데이션 효과', ), @@ -1363,7 +1365,7 @@ const ko = T( distMacOS: 'App Store를 통해 배포', distHarmony: 'AppGallery를 통해 배포', distWeb: '신청 필요', - distWindows: '閒言 공식 웹사이트에서 다운로드', + distWindows: 'Microsoft Store에서 배포', ), auth: TAuth( welcomeBack: '돌아오신 것을 환영합니다', @@ -1633,6 +1635,7 @@ const ko = T( shaderBackground: '효과 배경', soundFeedback: '소리 피드백', showOnNextLaunch: '다음 실행 시 온보딩 표시', + knowNewFeatures: 'V{0} 새로운 기능 알아보기', completeSetup: '완료, Xianyan 시작', ), theme: TTheme( diff --git a/lib/l10n/languages/pt.dart b/lib/l10n/languages/pt.dart index ce4c187d..e8c66911 100644 --- a/lib/l10n/languages/pt.dart +++ b/lib/l10n/languages/pt.dart @@ -878,6 +878,8 @@ const pt = T( splitViewRatioTitle: 'Proporção de tela dividida', splitViewEnabled: 'Tela dividida', splitViewEnabledSubtitle: 'Ativar tela dividida em tela larga', + workbenchEnabled: 'Modo bancada', + workbenchEnabledSubtitle: 'Ativar layout bancada de três colunas estilo WeChat PC em tela larga (nav+lista+detalhe)', shaderBackground: 'Plano de fundo shader', shaderBackgroundSubtitle: 'Efeito de gradiente fluido nos cartões de citações', ), @@ -1406,7 +1408,7 @@ const pt = T( distMacOS: 'Distribuído via App Store', distHarmony: 'Distribuído via AppGallery', distWeb: 'Solicitação necessária', - distWindows: 'Baixado do site oficial do Xianyan', + distWindows: 'Distribuído pela Microsoft Store', ), auth: TAuth( welcomeBack: 'Bem-vindo de volta', @@ -1690,6 +1692,7 @@ const pt = T( shaderBackground: 'Fundo com efeitos', soundFeedback: 'Resposta sonora', showOnNextLaunch: 'Mostrar introdução no próximo início', + knowNewFeatures: 'Conhecer as funções da V{0}', completeSetup: 'Concluído, entrar no Xianyan', ), theme: TTheme( diff --git a/lib/l10n/languages/ru.dart b/lib/l10n/languages/ru.dart index 7897207e..d0b31bcb 100644 --- a/lib/l10n/languages/ru.dart +++ b/lib/l10n/languages/ru.dart @@ -876,6 +876,8 @@ const ru = T( splitViewRatioTitle: 'Пропорция разделённого вида', splitViewEnabled: 'Разделённый вид', splitViewEnabledSubtitle: 'Включить разделённый вид на широком экране', + workbenchEnabled: 'Режим верстака', + workbenchEnabledSubtitle: 'Включить трехколоночный макет верстака в стиле WeChat PC на широких экранах (нав+список+детали)', shaderBackground: 'Шейдерный фон', shaderBackgroundSubtitle: 'Эффект плавного градиента на карточках цитат', ), @@ -1402,7 +1404,7 @@ const ru = T( distMacOS: 'Распространение через App Store', distHarmony: 'Распространение через AppGallery', distWeb: 'Требуется заявка', - distWindows: 'Загрузка с официального сайта Xianyan', + distWindows: 'Распространяется через Microsoft Store', ), auth: TAuth( welcomeBack: 'С возвращением', @@ -1685,6 +1687,7 @@ const ru = T( shaderBackground: 'Анимированный фон', soundFeedback: 'Звуковая обратная связь', showOnNextLaunch: 'Показать вводный экран при следующем запуске', + knowNewFeatures: 'Узнать о новых функциях V{0}', completeSetup: 'Готово, войти в Xianyan', ), theme: TTheme( diff --git a/lib/l10n/languages/zh_cn.dart b/lib/l10n/languages/zh_cn.dart index 84eb2a61..c2f3bcfe 100644 --- a/lib/l10n/languages/zh_cn.dart +++ b/lib/l10n/languages/zh_cn.dart @@ -862,6 +862,8 @@ const zhCN = T( splitViewRatioTitle: '分屏比例', splitViewEnabled: '分屏功能', splitViewEnabledSubtitle: '宽屏时启用左右分屏布局', + workbenchEnabled: '工作台模式', + workbenchEnabledSubtitle: '宽屏下启用微信PC式三栏工作台布局(导航栏+列表+详情)', shaderBackground: '特效背景', shaderBackgroundSubtitle: '句子卡片流体渐变特效', ), @@ -1342,7 +1344,7 @@ const zhCN = T( distMacOS: '由App Store提供分发', distHarmony: '由AppGallery提供分发', distWeb: '需申请', - distWindows: '由闲言官网提供下载', + distWindows: '由 Microsoft Store 分发', ), auth: TAuth( welcomeBack: '欢迎回来', @@ -1611,6 +1613,7 @@ const zhCN = T( shaderBackground: '特效背景', soundFeedback: '音效反馈', showOnNextLaunch: '下次继续显示引导页', + knowNewFeatures: '了解 V{0} 新功能', completeSetup: '完成设置,进入闲言', ), theme: TTheme( diff --git a/lib/l10n/languages/zh_tw.dart b/lib/l10n/languages/zh_tw.dart index c0aa083c..3fdddd4b 100644 --- a/lib/l10n/languages/zh_tw.dart +++ b/lib/l10n/languages/zh_tw.dart @@ -862,6 +862,8 @@ const zhTW = T( splitViewRatioTitle: '分屏比例', splitViewEnabled: '分屏功能', splitViewEnabledSubtitle: '寬屏時啟用左右分屏佈局', + workbenchEnabled: '工作台模式', + workbenchEnabledSubtitle: '寬屏下啟用微信PC式三欄工作台佈局(導航欄+列表+詳情)', shaderBackground: '特效背景', shaderBackgroundSubtitle: '句子卡片流體漸變特效', ), @@ -1341,7 +1343,7 @@ const zhTW = T( distMacOS: '由App Store提供分發', distHarmony: '由AppGallery提供分發', distWeb: '需申請', - distWindows: '由閒言官網提供下載', + distWindows: '由 Microsoft Store 分發', ), auth: TAuth( welcomeBack: '歡迎回來', @@ -1610,6 +1612,7 @@ const zhTW = T( shaderBackground: '特效背景', soundFeedback: '音效回饋', showOnNextLaunch: '下次繼續顯示引導頁', + knowNewFeatures: '了解 V{0} 新功能', completeSetup: '完成設定,進入閒言', ), theme: TTheme( diff --git a/lib/l10n/types/t_onboarding.dart b/lib/l10n/types/t_onboarding.dart index fe84de29..46db7935 100644 --- a/lib/l10n/types/t_onboarding.dart +++ b/lib/l10n/types/t_onboarding.dart @@ -71,6 +71,7 @@ class TOnboarding { required this.shaderBackground, required this.soundFeedback, required this.showOnNextLaunch, + required this.knowNewFeatures, required this.completeSetup, }); @@ -269,6 +270,9 @@ class TOnboarding { /// 下次继续显示引导页 final String showOnNextLaunch; + /// 了解 V{0} 新功能({0}为版本号占位符) + final String knowNewFeatures; + /// 完成设置按钮 final String completeSetup; @@ -336,6 +340,7 @@ class TOnboarding { 'shaderBackground': shaderBackground, 'soundFeedback': soundFeedback, 'showOnNextLaunch': showOnNextLaunch, + 'knowNewFeatures': knowNewFeatures, 'completeSetup': completeSetup, }; @@ -533,6 +538,9 @@ class TOnboarding { showOnNextLaunch: map['showOnNextLaunch']?.isNotEmpty == true ? map['showOnNextLaunch']! : (fallback?.showOnNextLaunch ?? ''), + knowNewFeatures: map['knowNewFeatures']?.isNotEmpty == true + ? map['knowNewFeatures']! + : (fallback?.knowNewFeatures ?? ''), completeSetup: map['completeSetup']?.isNotEmpty == true ? map['completeSetup']! : (fallback?.completeSetup ?? ''), diff --git a/lib/l10n/types/t_settings_display.dart b/lib/l10n/types/t_settings_display.dart index a823aeab..19e586e9 100644 --- a/lib/l10n/types/t_settings_display.dart +++ b/lib/l10n/types/t_settings_display.dart @@ -41,6 +41,8 @@ class TSettingsDisplay { required this.splitViewRatioTitle, required this.splitViewEnabled, required this.splitViewEnabledSubtitle, + required this.workbenchEnabled, + required this.workbenchEnabledSubtitle, required this.shaderBackground, required this.shaderBackgroundSubtitle, }); @@ -144,6 +146,12 @@ class TSettingsDisplay { /// 宽屏时启用左右分屏布局 final String splitViewEnabledSubtitle; + /// 工作台模式 + final String workbenchEnabled; + + /// 宽屏下启用微信PC式三栏工作台布局(导航栏+列表+详情) + final String workbenchEnabledSubtitle; + /// 特效背景 final String shaderBackground; @@ -184,6 +192,8 @@ class TSettingsDisplay { 'splitViewRatioTitle': splitViewRatioTitle, 'splitViewEnabled': splitViewEnabled, 'splitViewEnabledSubtitle': splitViewEnabledSubtitle, + 'workbenchEnabled': workbenchEnabled, + 'workbenchEnabledSubtitle': workbenchEnabledSubtitle, 'shaderBackground': shaderBackground, 'shaderBackgroundSubtitle': shaderBackgroundSubtitle, }; @@ -290,6 +300,12 @@ class TSettingsDisplay { splitViewEnabledSubtitle: map['splitViewEnabledSubtitle']?.isNotEmpty == true ? map['splitViewEnabledSubtitle']! : (fallback?.splitViewEnabledSubtitle ?? ''), + workbenchEnabled: map['workbenchEnabled']?.isNotEmpty == true + ? map['workbenchEnabled']! + : (fallback?.workbenchEnabled ?? ''), + workbenchEnabledSubtitle: map['workbenchEnabledSubtitle']?.isNotEmpty == true + ? map['workbenchEnabledSubtitle']! + : (fallback?.workbenchEnabledSubtitle ?? ''), shaderBackground: map['shaderBackground']?.isNotEmpty == true ? map['shaderBackground']! : (fallback?.shaderBackground ?? ''), diff --git a/lib/main.dart b/lib/main.dart index b21d3221..1697ef98 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -41,6 +41,7 @@ import 'features/settings/services/settings_change_logger.dart'; import 'features/auth/providers/auth_provider.dart'; import 'features/discover/services/rss_service.dart'; import 'features/discover/services/exchange_rate_service.dart'; +import 'package:path_provider/path_provider.dart'; bool kvStorageReady = false; bool _liquidGlassReady = false; @@ -68,6 +69,10 @@ Future _appMain() async { minimumSize: Size(400, 600), title: '闲言', center: true, + // 隐藏系统标题栏,使用自定义软件样式标题栏(DesktopWindowTitleBar) + titleBarStyle: TitleBarStyle.hidden, + // macOS: 隐藏原生红黄绿按钮,由 Flutter 侧自绘 + skipTaskbar: false, ); await windowManager.waitUntilReadyToShow(windowOptions, () async { await windowManager.show(); @@ -287,8 +292,18 @@ Future _appMain() async { // 初始化 Catcher2 异常捕获并启动应用 // 使用 rootWidget 方式,Catcher2 内部调用 runApp,避免 Zone mismatch + // 预先获取临时目录作为截图保存路径,避免 Catcher2 输出 "Screenshots path is empty" 警告 + String catcherScreenshotsPath = ''; + try { + final tempDir = await getTemporaryDirectory(); + catcherScreenshotsPath = '${tempDir.path}/catcher_screenshots'; + } catch (e) { + // 获取失败时保持空字符串,Catcher2 会输出警告但不影响功能 + } + if (_liquidGlassReady) { Catcher2ConfigService.instance.init( + screenshotsPath: catcherScreenshotsPath, rootWidget: LiquidGlassWidgets.wrap( child: ProviderScope( overrides: [ @@ -304,6 +319,7 @@ Future _appMain() async { ); } else { Catcher2ConfigService.instance.init( + screenshotsPath: catcherScreenshotsPath, rootWidget: ProviderScope( overrides: [ authStateProvider.overrideWith((ref) => ref.watch(authProvider)), diff --git a/lib/shared/widgets/adaptive/adaptive_back_button.dart b/lib/shared/widgets/adaptive/adaptive_back_button.dart index a8ea69ed..0ce27282 100644 --- a/lib/shared/widgets/adaptive/adaptive_back_button.dart +++ b/lib/shared/widgets/adaptive/adaptive_back_button.dart @@ -1,13 +1,14 @@ /// ============================================================ /// 闲言APP — 自适应返回按钮 /// 创建时间: 2026-05-22 -/// 更新时间: 2026-05-22 +/// 更新时间: 2026-06-19 /// 作用: 所有平台统一显示Cupertino风格返回按钮 /// 当导航栈可返回时显示,否则隐藏 -/// 上次更新: 修复移动端不显示返回按钮的问题 +/// 上次更新: 接入 appCanPop/appPop,工作台模式下正确感知右栏栈状态 /// ============================================================ import 'package:flutter/cupertino.dart'; +import 'package:xianyan/core/router/app_nav_extension.dart'; import 'package:xianyan/core/theme/app_theme.dart'; class AdaptiveBackButton extends StatelessWidget { @@ -15,14 +16,14 @@ class AdaptiveBackButton extends StatelessWidget { @override Widget build(BuildContext context) { - final canPop = Navigator.of(context).canPop(); + final canPop = context.appCanPop(); if (!canPop) return const SizedBox.shrink(); final ext = AppTheme.ext(context); return CupertinoButton( padding: EdgeInsets.zero, - onPressed: () => Navigator.of(context).maybePop(), + onPressed: () => context.appPop(), child: Icon( CupertinoIcons.chevron_left, color: ext.accent, diff --git a/lib/shared/widgets/display/appbar_date_display.dart b/lib/shared/widgets/display/appbar_date_display.dart index 7bbd23b7..bc801cd7 100644 --- a/lib/shared/widgets/display/appbar_date_display.dart +++ b/lib/shared/widgets/display/appbar_date_display.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — AppBar拾光栏显示组件 // 创建时间: 2026-05-20 -// 更新时间: 2026-06-12 +// 更新时间: 2026-06-19 // 作用: 可配置的拾光栏显示,支持多信息组合+轮播 -// 上次更新: 修复Stream每次重建重置+ref.watch移到build顶层避免paint阶段冲突 +// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0 // ============================================================ import 'dart:async'; @@ -17,6 +17,7 @@ import 'package:xianyan/core/theme/app_spacing.dart'; import 'package:xianyan/core/theme/app_theme.dart'; import 'package:xianyan/core/theme/app_typography.dart'; import 'package:xianyan/core/utils/logger.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu; import 'package:xianyan/features/settings/providers/date_display_provider.dart'; import 'package:xianyan/core/services/weather/weather_info_provider.dart'; @@ -35,7 +36,7 @@ final ipCacheProvider = Provider((ref) { if (raw == null || raw.isEmpty) return null; final map = jsonDecode(raw) as Map; - final cachedTime = map['_cache_time'] as int? ?? 0; + final cachedTime = SafeJson.parseInt(map['_cache_time']); final now = DateTime.now().millisecondsSinceEpoch; const cacheExpiry = Duration(hours: 24); diff --git a/lib/shared/widgets/display/themed_icon.dart b/lib/shared/widgets/display/themed_icon.dart new file mode 100644 index 00000000..fecbdb09 --- /dev/null +++ b/lib/shared/widgets/display/themed_icon.dart @@ -0,0 +1,214 @@ +/// ============================================================ +/// 闲言APP — 动态主题色图标组件 +/// 创建时间: 2026-06-19 +/// 更新时间: 2026-06-19 +/// 作用: 支持动态主题色填充的图标组件,随主题变化 +/// 通过 ThemedIconColor 枚举映射到 AppThemeExtension 颜色字段 +/// 支持圆形背景(ClipOval + Container)和 RadialGradient 光晕效果 +/// 上次更新: 首次创建 +/// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:xianyan/core/theme/app_theme.dart'; + +// ============================================================ +// 枚举定义 +// ============================================================ + +/// 主题图标颜色类型 — 控制 [ThemedIcon] 的颜色映射 +/// +/// 每个枚举值对应 [AppThemeExtension] 中的一个颜色字段, +/// 切换主题(日/夜/AMOLED)时图标颜色自动跟随变化。 +enum ThemedIconColor { + /// 强调色 — ext.accent + accent, + /// 成功 — 绿色,ext.successColor + success, + /// 警告 — 橙色,ext.warningColor + warning, + /// 错误 — 红色,ext.errorColor + error, + /// 信息 — 蓝色,ext.infoColor + info, + /// iOS 系统蓝 — ext.iconTintBlue + blue, + /// iOS 系统紫 — ext.iconTintPurple + purple, + /// iOS 系统紫红 — ext.iconTintViolet + violet, + /// iOS 系统青 — ext.iconTintCyan + cyan, + /// iOS 系统薄荷绿 — ext.iconTintMint + mint, + /// iOS 系统黄 — ext.iconTintYellow + yellow, + /// iOS 系统灰 — ext.iconTintGrey + grey, + /// 主图标色 — ext.iconPrimary + primary, + /// 次图标色 — ext.iconSecondary + secondary, + /// 提示文字色 — ext.textHint + hint, +} + +// ============================================================ +// ThemedIcon 组件 +// ============================================================ + +/// 动态主题色图标组件 +/// +/// 根据 [colorType] 将图标颜色映射到 [AppThemeExtension] 的对应字段, +/// 支持 Cupertino 风格的圆形背景和光晕效果,随主题动态变化。 +/// +/// 示例: +/// ```dart +/// // 基础用法 — 强调色图标 +/// ThemedIcon(icon: CupertinoIcons.star_fill, colorType: ThemedIconColor.accent); +/// +/// // 带圆形背景 — 适合分类入口 +/// ThemedIcon( +/// icon: CupertinoIcons.heart_fill, +/// colorType: ThemedIconColor.error, +/// withBackground: true, +/// ); +/// +/// // 带光晕效果 — 适合新功能引导 +/// ThemedIcon( +/// icon: CupertinoIcons.sparkles, +/// colorType: ThemedIconColor.violet, +/// withGlow: true, +/// size: 32, +/// ); +/// ``` +class ThemedIcon extends StatelessWidget { + const ThemedIcon({ + super.key, + required this.icon, + this.size = 24.0, + this.colorType = ThemedIconColor.accent, + this.withBackground = false, + this.backgroundOpacity = 0.12, + this.withGlow = false, + }); + + // ---- 图标数据 ---- + + /// 图标数据,通常传入 [CupertinoIcons] 中的值 + final IconData icon; + + // ---- 尺寸 ---- + + /// 图标尺寸,默认 24.0 + final double size; + + // ---- 颜色类型 ---- + + /// 颜色映射类型,控制图标颜色,默认 [ThemedIconColor.accent] + final ThemedIconColor colorType; + + // ---- 圆形背景 ---- + + /// 是否渲染圆形背景,默认 false + final bool withBackground; + + /// 背景透明度(0.0 ~ 1.0),默认 0.12 + final double backgroundOpacity; + + // ---- 光晕效果 ---- + + /// 是否渲染 RadialGradient 光晕,默认 false + final bool withGlow; + + // ============================================================ + // 私有方法 + // ============================================================ + + /// 根据 [colorType] 解析主题颜色 + Color _resolveColor(AppThemeExtension ext) { + return switch (colorType) { + ThemedIconColor.accent => ext.accent, + ThemedIconColor.success => ext.successColor, + ThemedIconColor.warning => ext.warningColor, + ThemedIconColor.error => ext.errorColor, + ThemedIconColor.info => ext.infoColor, + ThemedIconColor.blue => ext.iconTintBlue, + ThemedIconColor.purple => ext.iconTintPurple, + ThemedIconColor.violet => ext.iconTintViolet, + ThemedIconColor.cyan => ext.iconTintCyan, + ThemedIconColor.mint => ext.iconTintMint, + ThemedIconColor.yellow => ext.iconTintYellow, + ThemedIconColor.grey => ext.iconTintGrey, + ThemedIconColor.primary => ext.iconPrimary, + ThemedIconColor.secondary => ext.iconSecondary, + ThemedIconColor.hint => ext.textHint, + }; + } + + /// 构建带圆形背景的图标(Cupertino 风格:ClipOval + Container + Icon) + /// + /// 背景圆直径为图标尺寸的 1.6 倍,颜色为图标色 × [backgroundOpacity]。 + Widget _buildWithBackground(Color iconColor) { + final bgSize = size * 1.6; + return SizedBox( + width: bgSize, + height: bgSize, + child: ClipOval( + child: Container( + color: iconColor.withValues(alpha: backgroundOpacity), + alignment: Alignment.center, + child: Icon(icon, size: size, color: iconColor), + ), + ), + ); + } + + /// 构建带光晕效果的图标 + /// + /// 外层为 [RadialGradient] 光晕(直径为图标尺寸的 2.2 倍), + /// 内层根据 [withBackground] 决定是否再叠加圆形背景。 + /// 参考 new_features_dialog.dart 中的 _buildIconWithGlow 实现。 + Widget _buildWithGlow(Color iconColor) { + final glowSize = size * 2.2; + return Container( + width: glowSize, + height: glowSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + iconColor.withValues(alpha: 0.25), + iconColor.withValues(alpha: 0.08), + CupertinoColors.transparent, + ], + stops: const [0.0, 0.6, 1.0], + ), + ), + child: Center( + child: withBackground + ? _buildWithBackground(iconColor) + : Icon(icon, size: size, color: iconColor), + ), + ); + } + + // ============================================================ + // 构建 + // ============================================================ + + @override + Widget build(BuildContext context) { + final ext = Theme.of(context).extension()!; + final iconColor = _resolveColor(ext); + + // 优先级:光晕 > 背景 > 纯图标 + if (withGlow) { + return _buildWithGlow(iconColor); + } + if (withBackground) { + return _buildWithBackground(iconColor); + } + return Icon(icon, size: size, color: iconColor); + } +} diff --git a/lib/shared/widgets/input/setting_row.dart b/lib/shared/widgets/input/setting_row.dart index 6e79ae4d..b209b08f 100644 --- a/lib/shared/widgets/input/setting_row.dart +++ b/lib/shared/widgets/input/setting_row.dart @@ -23,6 +23,7 @@ class SettingRow extends StatelessWidget { this.icon, this.trailing, this.onTap, + this.isSelected = false, }); final String title; @@ -30,38 +31,60 @@ class SettingRow extends StatelessWidget { final Widget? trailing; final VoidCallback? onTap; + /// 是否选中(工作台模式下高亮当前右栏对应的项) + final bool isSelected; + @override Widget build(BuildContext context) { final ext = AppTheme.ext(context); - return CupertinoButton( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - onPressed: onTap, - child: Row( - children: [ - if (icon != null) - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: ext.accent.withValues(alpha: 0.12), - borderRadius: AppRadius.smBorder, + return Container( + decoration: isSelected + ? BoxDecoration( + color: ext.accent.withValues(alpha: 0.1), + borderRadius: AppRadius.smBorder, + ) + : null, + child: CupertinoButton( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + onPressed: onTap, + child: Row( + children: [ + if (icon != null) + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: ext.accent.withValues(alpha: 0.12), + borderRadius: AppRadius.smBorder, + ), + child: Icon( + icon, + size: 16, + color: isSelected ? ext.accent : ext.accent, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + title, + style: AppTypography.body.copyWith( + color: isSelected ? ext.accent : ext.textPrimary, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), ), - child: Icon(icon, size: 16, color: ext.accent), ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - title, - style: AppTypography.body.copyWith(color: ext.textPrimary), - ), - ), - if (trailing != null) trailing!, - if (trailing == null) - Icon(CupertinoIcons.chevron_right, size: 16, color: ext.textHint), - ], + if (trailing != null) trailing!, + if (trailing == null) + Icon( + CupertinoIcons.chevron_right, + size: 16, + color: isSelected ? ext.accent : ext.textHint, + ), + ], + ), ), ); } diff --git a/lib/shared/widgets/qrcode_scanner_page.dart b/lib/shared/widgets/qrcode_scanner_page.dart index 3b807afc..eb98e0d0 100644 --- a/lib/shared/widgets/qrcode_scanner_page.dart +++ b/lib/shared/widgets/qrcode_scanner_page.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 通用二维码扫描页面 /// 创建时间: 2026-06-04 -/// 更新时间: 2026-06-12 +/// 更新时间: 2026-06-19 /// 作用: 提供完整的二维码扫描功能,支持URL/文本/vCard/WiFi/闲言scheme等类型识别 -/// 上次更新: 从 features/shared/presentation/ 迁移至 shared/widgets/ +/// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? /// ============================================================ import 'dart:async'; @@ -16,6 +16,7 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:image_picker/image_picker.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:xianyan/core/utils/safe_json.dart'; import '../../core/theme/app_theme.dart'; import '../../core/theme/app_spacing.dart'; @@ -219,7 +220,7 @@ class _QrcodeScannerPageState extends State actionText: '查看设备', deviceAlias: data['alias'] as String?, deviceIp: data['ip'] as String?, - devicePort: data['port'] as int?, + devicePort: data['port'] == null ? null : SafeJson.parseInt(data['port']), deviceUrl: data['url'] as String?, ); } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 5c214503..5b9ca99c 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -9,12 +9,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include @@ -28,6 +30,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_acrylic_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAcrylicPlugin"); + flutter_acrylic_plugin_register_with_registrar(flutter_acrylic_registrar); g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); @@ -46,6 +51,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); + g_autoptr(FlPluginRegistrar) tray_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin"); + tray_manager_plugin_register_with_registrar(tray_manager_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index de9de248..2227549d 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,12 +6,14 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux desktop_drop file_selector_linux + flutter_acrylic flutter_secure_storage_linux flutter_webrtc gtk record_linux rive_native screen_retriever_linux + tray_manager url_launcher_linux window_manager ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 157145dd..f7c59e2a 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -17,11 +17,12 @@ import flutter_app_group_directory import flutter_image_compress_macos import flutter_inappwebview_macos import flutter_local_notifications -import flutter_secure_storage_macos +import flutter_secure_storage_darwin import flutter_tts import flutter_webrtc import gal import local_auth_darwin +import macos_window_utils import mobile_scanner import nearby_service import network_info_plus @@ -35,6 +36,7 @@ import share_plus import shared_preferences_foundation import speech_to_text import sqflite_darwin +import tray_manager import url_launcher_macos import video_compress import video_player_avfoundation @@ -54,11 +56,12 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin")) InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) - FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin")) FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin")) LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) + MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) NearbyServicePlugin.register(with: registry.registrar(forPlugin: "NearbyServicePlugin")) NetworkInfoPlusPlugin.register(with: registry.registrar(forPlugin: "NetworkInfoPlusPlugin")) @@ -72,6 +75,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SpeechToTextPlugin.register(with: registry.registrar(forPlugin: "SpeechToTextPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) VideoCompressPlugin.register(with: registry.registrar(forPlugin: "VideoCompressPlugin")) VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index b1365504..87b8f44c 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -44,6 +44,8 @@ PODS: - local_auth_darwin (0.0.1): - Flutter - FlutterMacOS + - macos_window_utils (1.0.0): + - FlutterMacOS - mobile_scanner (7.0.0): - Flutter - FlutterMacOS @@ -78,31 +80,8 @@ PODS: - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS - - sqlite3 (3.52.0): - - sqlite3/common (= 3.52.0) - - sqlite3/common (3.52.0) - - sqlite3/dbstatvtab (3.52.0): - - sqlite3/common - - sqlite3/fts5 (3.52.0): - - sqlite3/common - - sqlite3/math (3.52.0): - - sqlite3/common - - sqlite3/perf-threadsafe (3.52.0): - - sqlite3/common - - sqlite3/rtree (3.52.0): - - sqlite3/common - - sqlite3/session (3.52.0): - - sqlite3/common - - sqlite3_flutter_libs (0.0.1): - - Flutter + - tray_manager (0.0.1): - FlutterMacOS - - sqlite3 (~> 3.52.0) - - sqlite3/dbstatvtab - - sqlite3/fts5 - - sqlite3/math - - sqlite3/perf-threadsafe - - sqlite3/rtree - - sqlite3/session - url_launcher_macos (0.0.1): - FlutterMacOS - video_compress (0.3.0): @@ -135,6 +114,7 @@ DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) - gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`) - local_auth_darwin (from `Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin`) + - macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`) - mobile_scanner (from `Flutter/ephemeral/.symlinks/plugins/mobile_scanner/darwin`) - nearby_service (from `Flutter/ephemeral/.symlinks/plugins/nearby_service/darwin`) - network_info_plus (from `Flutter/ephemeral/.symlinks/plugins/network_info_plus/macos`) @@ -148,7 +128,7 @@ DEPENDENCIES: - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - speech_to_text (from `Flutter/ephemeral/.symlinks/plugins/speech_to_text/darwin`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) - - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`) + - tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - video_compress (from `Flutter/ephemeral/.symlinks/plugins/video_compress/macos`) - video_player_avfoundation (from `Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin`) @@ -160,7 +140,6 @@ SPEC REPOS: - CwlCatchException - CwlCatchExceptionSupport - OrderedSet - - sqlite3 - WebRTC-SDK EXTERNAL SOURCES: @@ -200,6 +179,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/gal/darwin local_auth_darwin: :path: Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin + macos_window_utils: + :path: Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos mobile_scanner: :path: Flutter/ephemeral/.symlinks/plugins/mobile_scanner/darwin nearby_service: @@ -226,8 +207,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/speech_to_text/darwin sqflite_darwin: :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin - sqlite3_flutter_libs: - :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin + tray_manager: + :path: Flutter/ephemeral/.symlinks/plugins/tray_manager/macos url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos video_compress: @@ -246,7 +227,7 @@ SPEC CHECKSUMS: connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e CwlCatchException: 7acc161b299a6de7f0a46a6ed741eae2c8b4d75a CwlCatchExceptionSupport: 54ccab8d8c78907b57f99717fb19d4cc3bce02dc - desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43 + desktop_drop: 10a3e6a7fa9dbe350541f2574092fecfa345a07b device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 file_picker: 70164d9778c42c47218d6cd79ce435de0856b11a file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 @@ -260,6 +241,7 @@ SPEC CHECKSUMS: FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 gal: baecd024ebfd13c441269ca7404792a7152fde89 local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb + macos_window_utils: 23f54331a0fd51eea9e0ed347253bf48fd379d1d mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 nearby_service: 608702f35ef2b2f4d10b29b49c9a1bd24ae2ff03 network_info_plus: 21d1cd6a015ccb2fdff06a1fbfa88d54b4e92f61 @@ -274,8 +256,7 @@ SPEC CHECKSUMS: shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb speech_to_text: 3b313d98516d3d0406cea424782ec25470c59d19 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 - sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921 - sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab + tray_manager: 19c369010e59c073f13b82354d78a35f7972d609 url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd video_compress: 752b161da855df2492dd1a8fa899743cc8fe9534 video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52 diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 09aac55a..ee7fe639 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -15,7 +15,7 @@ @@ -31,7 +31,7 @@ @@ -66,11 +66,14 @@ + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index b3c17614..5e14c5f1 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -1,13 +1,143 @@ +/// ============================================================ +/// 闲言APP — macOS 应用代理 +/// 创建时间: 2026-06-02 +/// 更新时间: 2026-06-19 +/// 作用: 应用启动入口,注册应用级 MethodChannel(Dock 徽章/菜单栏金句/Spotlight 索引) +/// 上次更新: 新增 NSStatusItem 菜单栏金句、NSDockTile 徽章、CoreSpotlight 索引能力 +/// ============================================================ + import Cocoa import FlutterMacOS +import CoreSpotlight @main class AppDelegate: FlutterAppDelegate { + // ============================================================ + // MARK: - 属性 + // ============================================================ + + /// 菜单栏金句 NSStatusItem(需强引用,否则会被释放) + private var statusItem: NSStatusItem? + + /// 应用级 MethodChannel(apps.xy.xianyan/macos.app) + private var methodChannel: FlutterMethodChannel? + + /// 当前菜单栏金句完整内容(用于点击复制,避免从截断标题反解析) + private var currentSentence: String = "" + + // ============================================================ + // MARK: - 应用生命周期 + // ============================================================ + + /// 应用启动完成:注册应用级 MethodChannel + override func applicationDidFinishLaunching(_ notification: Notification) { + super.applicationDidFinishLaunching(notification) + + // 获取 FlutterViewController(窗口已在 NIB 加载时创建) + guard let controller = NSApplication.shared.windows.first?.contentViewController as? FlutterViewController else { + return + } + + methodChannel = FlutterMethodChannel( + name: "apps.xy.xianyan/macos.app", + binaryMessenger: controller.engine.binaryMessenger + ) + + methodChannel?.setMethodCallHandler { [weak self] call, result in + switch call.method { + // ---------- Dock 徽章(NSDockTile) ---------- + case "setDockBadge": + let args = call.arguments as? [String: Any] + let count = args?["count"] as? Int ?? 0 + NSApp.dockTile.badgeLabel = count > 0 ? "\(count)" : nil + result(nil) + + // ---------- 菜单栏金句(NSStatusItem) ---------- + case "updateStatusBarSentence": + let args = call.arguments as? [String: Any] + let sentence = args?["sentence"] as? String ?? "" + self?.updateStatusItem(sentence: sentence) + result(nil) + + // ---------- Spotlight 索引(CoreSpotlight) ---------- + case "indexSpotlightItems": + let args = call.arguments as? [String: Any] + let items = args?["items"] as? [[String: Any]] ?? [] + self?.indexSpotlightItems(items: items) + result(nil) + + case "clearSpotlightIndex": + CSSearchableIndex.default().deleteAllSearchableItems { error in + if let error = error { + result(FlutterError(code: "SPOTLIGHT_ERROR", + message: error.localizedDescription, + details: nil)) + } else { + result(nil) + } + } + + default: + result(FlutterMethodNotImplemented) + } + } + } + + /// 窗口关闭后不退出应用,支持托盘常驻 override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true + return false } override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } + + // ============================================================ + // MARK: - 菜单栏金句(NSStatusItem) + // ============================================================ + + /// 创建或更新菜单栏金句,截断到 30 字符显示 + private func updateStatusItem(sentence: String) { + if statusItem == nil { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + statusItem?.button?.target = self + statusItem?.button?.action = #selector(copySentence(_:)) + } + currentSentence = sentence + let display = sentence.count > 30 ? String(sentence.prefix(30)) + "…" : sentence + statusItem?.button?.title = "💬 \(display)" + } + + /// 点击菜单栏金句复制完整内容到剪贴板 + @objc private func copySentence(_ sender: Any) { + guard !currentSentence.isEmpty else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(currentSentence, forType: .string) + } + + // ============================================================ + // MARK: - Spotlight 索引(CoreSpotlight) + // ============================================================ + + /// 将条目索引到 Spotlight,支持点击搜索结果跳转 + private func indexSpotlightItems(items: [[String: Any]]) { + let searchableItems = items.map { item in + let attributeSet = CSSearchableItemAttributeSet(itemContentType: "public.text") + attributeSet.title = item["title"] as? String + attributeSet.contentDescription = item["content"] as? String + attributeSet.keywords = [item["type"] as? String ?? "xianyan"] + + return CSSearchableItem( + uniqueIdentifier: item["id"] as? String ?? "", + domainIdentifier: "apps.xy.xianyan.\(item["type"] as? String ?? "item")", + attributeSet: attributeSet + ) + } + + CSSearchableIndex.default().indexSearchableItems(searchableItems) { error in + if let error = error { + print("Spotlight indexing error: \(error)") + } + } + } } diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index ba4da9e3..ba9383ae 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -32,6 +32,18 @@ NSApplication FLTEnableImpeller + + CFBundleURLTypes + + + CFBundleURLName + apps.xy.xianyan + CFBundleURLSchemes + + xianyan + + + UISupportsDocumentBrowser diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 3cc05eb2..c075f244 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -1,7 +1,25 @@ +/// ============================================================ +/// 闲言APP — macOS 主窗口 +/// 创建时间: 2026-06-02 +/// 更新时间: 2026-06-19 +/// 作用: 主窗口初始化,注册 apps.xy.xianyan/macos MethodChannel +/// 上次更新: 新增 Touch Bar 支持、NSSharingService 共享面板 +/// ============================================================ + import Cocoa import FlutterMacOS -class MainFlutterWindow: NSWindow { +class MainFlutterWindow: NSWindow, NSTouchBarDelegate { + // ============================================================ + // MARK: - 属性 + // ============================================================ + + /// 平台通道引用,用于 Touch Bar 按钮回调 + private var platformChannel: FlutterMethodChannel? + + /// Touch Bar 按钮配置 [{label: "加粗", action: "bold"}, ...] + private var touchBarItems: [[String: String]] = [] + override func awakeFromNib() { let flutterViewController = FlutterViewController() let windowFrame = self.frame @@ -10,6 +28,263 @@ class MainFlutterWindow: NSWindow { RegisterGeneratedPlugins(registry: flutterViewController) + // 在 Flutter 引擎启动前注册自定义 MethodChannel,避免 MissingPluginException + registerPlatformChannel(controller: flutterViewController) + super.awakeFromNib() } + + // ============================================================ + // MARK: - MethodChannel 注册 + // ============================================================ + + /// 注册 apps.xy.xianyan/macos 平台通道,对接 Dart 端 MacosPlatformService + private func registerPlatformChannel(controller: FlutterViewController) { + let channel = FlutterMethodChannel( + name: "apps.xy.xianyan/macos", + binaryMessenger: controller.engine.binaryMessenger + ) + platformChannel = channel + + channel.setMethodCallHandler { [weak self] (call, result) in + guard let self = self else { + result(FlutterError(code: "window_deallocated", + message: "MainFlutterWindow 已释放", + details: nil)) + return + } + + switch call.method { + // ---------- 主题同步 ---------- + case "setDarkMode": + let isDark = (call.arguments as? Bool) ?? false + self.setDarkMode(isDark: isDark) + result(nil) + + case "getSystemAppearance": + result(self.getSystemAppearance()) + + // ---------- 窗口管理 ---------- + case "setWindowTitle": + let title = (call.arguments as? String) ?? "" + self.title = title + result(nil) + + case "setTitleBarTransparent": + let transparent = (call.arguments as? Bool) ?? false + self.titlebarAppearsTransparent = transparent + result(nil) + + case "setTitleBarStyle": + let style = (call.arguments as? String) ?? "auto" + self.setTitleBarStyle(style: style) + result(nil) + + case "setToolbarVisible": + let visible = (call.arguments as? Bool) ?? true + if visible { + self.toolbar?.isVisible = true + } else { + self.toolbar?.isVisible = false + } + result(nil) + + case "setFullscreen": + let fullscreen = (call.arguments as? Bool) ?? false + self.setFullscreen(fullscreen: fullscreen) + result(nil) + + case "isFullscreen": + result(self.styleMask.contains(.fullScreen)) + + case "setMinSize": + guard let args = call.arguments as? [String: Any], + let width = args["width"] as? Double, + let height = args["height"] as? Double else { + result(FlutterError(code: "invalid_args", + message: "setMinSize 需要 {width, height}", + details: nil)) + return + } + self.minSize = NSSize(width: width, height: height) + result(nil) + + // ---------- 系统集成 ---------- + case "performHapticFeedback": + let type = (call.arguments as? String) ?? "generic" + self.performHapticFeedback(type: type) + result(nil) + + // ---------- Touch Bar 支持 ---------- + case "setTouchBarItems": + let args = call.arguments as? [String: Any] ?? [:] + let rawItems = args["items"] as? [[String: Any]] ?? [] + self.touchBarItems = rawItems.compactMap { item in + guard let label = item["label"] as? String, + let action = item["action"] as? String else { return nil } + return ["label": label, "action": action] + } + // 强制重建 Touch Bar + self.touchBar = nil + if !self.touchBarItems.isEmpty { + self.touchBar = self.makeTouchBar() + } + result(nil) + + // ---------- NSSharingService 共享 ---------- + case "showShareSheet": + let args = call.arguments as? [String: Any] ?? [:] + var shareItems: [Any] = [] + if let text = args["text"] as? String { + shareItems.append(text) + } + if let urlString = args["url"] as? String, let url = URL(string: urlString) { + shareItems.append(url) + } + if let imageBytes = args["imageBytes"] as? FlutterStandardTypedData, + let image = NSImage(data: imageBytes.data) { + shareItems.append(image) + } + guard !shareItems.isEmpty else { + result(FlutterError(code: "no_content", + message: "没有可分享的内容", + details: nil)) + return + } + let picker = NSSharingServicePicker(items: shareItems) + if let view = self.contentView { + picker.show(relativeTo: view.bounds, of: view, preferredEdge: .minY) + } + result(nil) + + default: + result(FlutterMethodNotImplemented) + } + } + } + + // ============================================================ + // MARK: - 主题同步实现 + // ============================================================ + + /// 设置标题栏明暗模式(影响 NSWindow.appearance) + /// + /// 自定义软件样式标题栏:titlebarAppearsTransparent = true, + /// 让 Flutter 侧的 DesktopWindowTitleBar 完全接管标题栏区域。 + private func setDarkMode(isDark: Bool) { + let appearance: NSAppearance = isDark + ? NSAppearance(named: .darkAqua)! + : NSAppearance(named: .aqua)! + NSApp.appearance = appearance + // 同步到所有窗口 + for window in NSApplication.shared.windows { + window.appearance = appearance + // 标题栏透明,由 Flutter 侧自绘标题栏 + window.titlebarAppearsTransparent = true + // 隐藏系统标题栏按钮(红黄绿),由 Flutter 侧自绘 + window.standardWindowButton(.closeButton)?.isHidden = true + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + window.standardWindowButton(.zoomButton)?.isHidden = true + // 标题栏高度为 0,让内容延伸到顶部 + if let contentView = window.contentView { + contentView.superview?.wantsLayer = true + } + } + } + + /// 获取系统当前外观(light / dark) + private func getSystemAppearance() -> String { + let appearance = NSApp.effectiveAppearance.bestMatch(from: [ + NSAppearance.Name.darkAqua, + NSAppearance.Name.aqua, + ]) ?? NSAppearance.Name.aqua + return appearance == .darkAqua ? "dark" : "light" + } + + // ============================================================ + // MARK: - 窗口管理实现 + // ============================================================ + + /// 设置标题栏样式(auto / light / dark) + private func setTitleBarStyle(style: String) { + let appearance: NSAppearance? + switch style { + case "light": + appearance = NSAppearance(named: .aqua) + case "dark": + appearance = NSAppearance(named: .darkAqua) + default: // auto + appearance = nil + } + for window in NSApplication.shared.windows { + window.appearance = appearance + } + } + + /// 设置窗口全屏 + private func setFullscreen(fullscreen: Bool) { + let isCurrentlyFullscreen = self.styleMask.contains(.fullScreen) + if fullscreen && !isCurrentlyFullscreen { + self.toggleFullScreen(nil) + } else if !fullscreen && isCurrentlyFullscreen { + self.toggleFullScreen(nil) + } + } + + // ============================================================ + // MARK: - 系统集成实现 + // ============================================================ + + /// 触感反馈(macOS 通过 NSHapticFeedbackManager 实现) + private func performHapticFeedback(type: String) { + let feedbackLevel: NSHapticFeedbackManager.FeedbackPattern + switch type { + case "alignment": + feedbackLevel = .alignment + case "levelChange": + feedbackLevel = .levelChange + default: + feedbackLevel = .generic + } + NSHapticFeedbackManager.defaultPerformer.perform( + feedbackLevel, + performanceTime: .now + ) + } + + // ============================================================ + // MARK: - Touch Bar 实现 + // ============================================================ + + /// 创建 Touch Bar(系统在需要时自动调用,仅带 Touch Bar 的 MacBook 显示) + override func makeTouchBar() -> NSTouchBar? { + guard !touchBarItems.isEmpty else { return nil } + let touchBar = NSTouchBar() + touchBar.delegate = self + touchBar.customizationIdentifier = NSTouchBar.CustomizationIdentifier("apps.xy.xianyan.touchbar") + touchBar.defaultItemIdentifiers = touchBarItems.enumerated().map { + NSTouchBarItem.Identifier("apps.xy.xianyan.touchbar.item.\($0.offset)") + } + return touchBar + } + + /// NSTouchBarDelegate:按索引创建 Touch Bar 按钮 + func touchBar(_ touchBar: NSTouchBar, makeItemForIdentifier identifier: NSTouchBarItem.Identifier) -> NSTouchBarItem? { + guard let index = Int(identifier.rawValue.components(separatedBy: ".").last ?? ""), + index < touchBarItems.count else { + return nil + } + let item = touchBarItems[index] + let button = NSButton(title: item["label"] ?? "", target: self, action: #selector(touchBarButtonClicked(_:))) + button.identifier = NSUserInterfaceItemIdentifier(rawValue: item["action"] ?? "") + let touchBarItem = NSCustomTouchBarItem(identifier: identifier) + touchBarItem.view = button + return touchBarItem + } + + /// Touch Bar 按钮点击回调,通过 channel.invokeMethod("touchBarAction", action) 通知 Dart + @objc private func touchBarButtonClicked(_ sender: NSButton) { + guard let action = sender.identifier?.rawValue else { return } + platformChannel?.invokeMethod("touchBarAction", arguments: action) + } } diff --git a/pubspec.macos.yaml b/pubspec.macos.yaml index c6eebcdf..0c74e4e7 100644 --- a/pubspec.macos.yaml +++ b/pubspec.macos.yaml @@ -141,6 +141,9 @@ dependencies: # --- 桌面端增强 --- desktop_drop: ^0.7.0 # 桌面端文件拖放接收 window_manager: ^0.5.1 # 桌面端窗口管理(替代bitsdojo_window) + tray_manager: ^0.5.3 # 跨平台系统托盘(macOS/Win/Linux) + macos_window_utils: ^1.9.1 # macOS NSWindow级精细控制(标题栏融合/侧边栏毛玻璃) + flutter_acrylic: ^1.1.4 # 窗口特效(macOS模糊/Win10 Acrylic/Win11 Mica) # --- 异常捕获 --- catcher_2: ^2.1.9 # 全局异常捕获+上报 @@ -248,7 +251,6 @@ dependencies: basic_utils: ^5.8.0 # 通用工具集(Base64/ASN1) wifi_iot: ^0.3.19 # WiFi IoT设备连接 nearby_service: ^0.2.1 # 近场设备发现+通信 - nearby_connections: ^4.1.1 # Google Nearby Connections(蓝牙发现+Wi-Fi Direct传输,仅Android/iOS) flutter_localizations: sdk: flutter # Flutter国际化支持 diff --git a/pubspec.ohos.yaml b/pubspec.ohos.yaml index 866bd0d5..6034bb9b 100644 --- a/pubspec.ohos.yaml +++ b/pubspec.ohos.yaml @@ -1,9 +1,9 @@ # ============================================================ # 闲言APP (Xianyan) — 鸿蒙端 pubspec 模板 # 创建时间: 2026-04-20 -# 更新时间: 2026-06-15 +# 更新时间: 2026-06-18 # 作用: 鸿蒙端依赖与资源配置模板(使用本地 packages/ 目录) -# 上次更新: 同步pubspec.yaml依赖升级 + 删除custom_lint/riverpod_lint + 新增analyzer/test_api/test overrides + record降级到^6.2.1 +# 上次更新: 补齐 tray_manager/macos_window_utils/flutter_acrylic 三库声明(Dart 编译时需解析 import 链,鸿蒙端 no-op 但必须存在) # 使用方式: # ⚠️ 此文件为模板,不要直接重命名为 pubspec.yaml 使用 # ============================================================ @@ -162,6 +162,10 @@ dependencies: # --- 桌面端增强 --- desktop_drop: ^0.7.0 # 桌面端文件拖放接收 window_manager: ^0.5.1 # 桌面端窗口管理(替代bitsdojo_window) + # 以下三库鸿蒙端不使用原生功能,但 Dart 编译时需解析 import 链,必须声明 + tray_manager: ^0.5.3 # 跨平台系统托盘(仅 macOS/Win/Linux 调用原生 API) + macos_window_utils: ^1.9.1 # macOS NSWindow 级精细控制(鸿蒙端 no-op) + flutter_acrylic: ^1.1.4 # 窗口特效(鸿蒙端 no-op) # --- 异常捕获 --- catcher_2: ^2.1.9 # 全局异常捕获+上报 @@ -287,7 +291,6 @@ dependencies: path: packages/wifi_iot nearby_service: # v0.2.1 | 近场设备发现+通信(本地化-鸿蒙适配) path: packages/nearby_service - nearby_connections: ^4.1.1 # Google Nearby Connections(蓝牙发现+Wi-Fi Direct传输,仅Android/iOS) flutter_localizations: sdk: flutter # Flutter国际化支持 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index a79d4203..80de0d9e 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -24,6 +25,7 @@ #include #include #include +#include #include #include @@ -40,6 +42,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DesktopDropPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterAcrylicPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterAcrylicPlugin")); FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( @@ -64,6 +68,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); SpeechToTextWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("SpeechToTextWindows")); + TrayManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("TrayManagerPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); WindowManagerPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 4278ddd2..ef353d53 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST connectivity_plus desktop_drop file_selector_windows + flutter_acrylic flutter_inappwebview_windows flutter_secure_storage_windows flutter_tts @@ -21,6 +22,7 @@ list(APPEND FLUTTER_PLUGIN_LIST screen_retriever_windows share_plus speech_to_text_windows + tray_manager url_launcher_windows window_manager ) diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index 29136b17..d009b26d 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -40,7 +40,7 @@ bool FlutterWindow::OnCreate() { flutter_controller_->ForceRedraw(); // Register Windows platform MethodChannel for theme control - const static std::string channel_name("com.xianyan.windows"); + const static std::string channel_name("apps.xy.xianyan/windows"); platform_channel_ = std::make_unique>( flutter_controller_->engine()->messenger(), channel_name, @@ -49,7 +49,9 @@ bool FlutterWindow::OnCreate() { platform_channel_->SetMethodCallHandler( [this](const flutter::MethodCall<>& call, std::unique_ptr> result) { - if (call.method_name() == "setDarkMode") { + const std::string& method = call.method_name(); + + if (method == "setDarkMode") { bool is_dark = false; if (const auto* args = std::get_if(call.arguments())) { auto it = args->find(flutter::EncodableValue("isDark")); @@ -59,6 +61,64 @@ bool FlutterWindow::OnCreate() { } Win32Window::SetDarkMode(GetHandle(), is_dark); result->Success(); + } else if (method == "setWindowTitle") { + // 设置窗口标题 + std::string title; + if (const auto* args = std::get_if(call.arguments())) { + auto it = args->find(flutter::EncodableValue("title")); + if (it != args->end() && std::holds_alternative(it->second)) { + title = std::get(it->second); + } + } + std::wstring wide_title(title.begin(), title.end()); + Win32Window::SetWindowTitle(GetHandle(), wide_title); + result->Success(); + } else if (method == "setFullscreen") { + // 进入/退出全屏 + bool fullscreen = false; + if (const auto* args = std::get_if(call.arguments())) { + auto it = args->find(flutter::EncodableValue("fullscreen")); + if (it != args->end() && std::holds_alternative(it->second)) { + fullscreen = std::get(it->second); + } + } + Win32Window::SetFullscreen(GetHandle(), fullscreen); + result->Success(); + } else if (method == "isFullscreen") { + // 查询全屏状态 + bool is_fullscreen = Win32Window::IsFullscreen(GetHandle()); + result->Success(flutter::EncodableValue(is_fullscreen)); + } else if (method == "setMinSize") { + // 设置最小尺寸 + unsigned int width = 0; + unsigned int height = 0; + if (const auto* args = std::get_if(call.arguments())) { + auto it_w = args->find(flutter::EncodableValue("width")); + auto it_h = args->find(flutter::EncodableValue("height")); + if (it_w != args->end() && std::holds_alternative(it_w->second)) { + width = static_cast(std::get(it_w->second)); + } + if (it_h != args->end() && std::holds_alternative(it_h->second)) { + height = static_cast(std::get(it_h->second)); + } + } + Win32Window::SetMinSize(GetHandle(), width, height); + result->Success(); + } else if (method == "performHapticFeedback") { + // 触觉反馈(Windows 用 MessageBeep 模拟) + int feedback_type = 0; + if (const auto* args = std::get_if(call.arguments())) { + auto it = args->find(flutter::EncodableValue("type")); + if (it != args->end() && std::holds_alternative(it->second)) { + feedback_type = std::get(it->second); + } + } + Win32Window::PerformHapticFeedback(GetHandle(), feedback_type); + result->Success(); + } else if (method == "getSystemAppearance") { + // 获取系统外观模式 + std::string appearance = Win32Window::GetSystemAppearance(); + result->Success(flutter::EncodableValue(appearance)); } else { result->NotImplemented(); } diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp index debae415..f50040f6 100644 --- a/windows/runner/win32_window.cpp +++ b/windows/runner/win32_window.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "resource.h" @@ -216,6 +217,22 @@ Win32Window::MessageHandler(HWND hwnd, case WM_DWMCOLORIZATIONCOLORCHANGED: UpdateTheme(hwnd); return 0; + + case WM_GETMINMAXINFO: { + // 处理窗口最小尺寸限制 + MINMAXINFO* mmi = reinterpret_cast(lparam); + if (min_width_ > 0 && min_height_ > 0) { + // 获取当前 DPI 缩放因子 + HMONITOR monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + // 设置最小跟踪尺寸(物理像素) + mmi->ptMinTrackSize.x = static_cast(min_width_ * scale_factor); + mmi->ptMinTrackSize.y = static_cast(min_height_ * scale_factor); + } + return 0; + } } return DefWindowProc(window_handle_, message, wparam, lparam); @@ -292,3 +309,116 @@ void Win32Window::SetDarkMode(HWND const window, bool dark_mode) { DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, &enable_dark_mode, sizeof(enable_dark_mode)); } + +// ============================================================ +// 静态成员变量初始化 +// ============================================================ + +bool Win32Window::fullscreen_ = false; +RECT Win32Window::saved_window_rect_ = {0}; +LONG Win32Window::saved_window_style_ = 0; +LONG Win32Window::saved_window_ex_style_ = 0; +WINDOWPLACEMENT Win32Window::saved_placement_ = {sizeof(WINDOWPLACEMENT)}; +unsigned int Win32Window::min_width_ = 400; +unsigned int Win32Window::min_height_ = 600; + +// ============================================================ +// 窗口管理扩展方法实现 +// ============================================================ + +void Win32Window::SetWindowTitle(HWND const window, + const std::wstring& title) { + SetWindowTextW(window, title.c_str()); +} + +void Win32Window::SetFullscreen(HWND const window, bool fullscreen) { + if (fullscreen == fullscreen_) { + return; // 状态未变化 + } + + if (fullscreen) { + // 进入全屏:保存当前窗口状态 + saved_window_style_ = GetWindowLong(window, GWL_STYLE); + saved_window_ex_style_ = GetWindowLong(window, GWL_EXSTYLE); + GetWindowPlacement(window, &saved_placement_); + GetWindowRect(window, &saved_window_rect_); + + // 移除标题栏和边框,设置为全屏样式 + LONG new_style = saved_window_style_ & ~WS_OVERLAPPEDWINDOW; + SetWindowLong(window, GWL_STYLE, new_style); + + // 获取屏幕尺寸并最大化 + HMONITOR monitor = MonitorFromWindow(window, MONITOR_DEFAULTTONEAREST); + MONITORINFO monitor_info; + monitor_info.cbSize = sizeof(monitor_info); + GetMonitorInfo(monitor, &monitor_info); + + SetWindowPos(window, HWND_TOP, monitor_info.rcMonitor.left, + monitor_info.rcMonitor.top, + monitor_info.rcMonitor.right - monitor_info.rcMonitor.left, + monitor_info.rcMonitor.bottom - monitor_info.rcMonitor.top, + SWP_NOZORDER | SWP_FRAMECHANGED | SWP_SHOWWINDOW); + + fullscreen_ = true; + } else { + // 退出全屏:恢复保存的窗口状态 + SetWindowLong(window, GWL_STYLE, saved_window_style_); + SetWindowLong(window, GWL_EXSTYLE, saved_window_ex_style_); + SetWindowPlacement(window, &saved_placement_); + SetWindowPos(window, nullptr, saved_window_rect_.left, + saved_window_rect_.top, + saved_window_rect_.right - saved_window_rect_.left, + saved_window_rect_.bottom - saved_window_rect_.top, + SWP_NOZORDER | SWP_FRAMECHANGED | SWP_SHOWWINDOW); + + fullscreen_ = false; + } +} + +bool Win32Window::IsFullscreen(HWND const window) { + return fullscreen_; +} + +void Win32Window::SetMinSize(HWND const window, unsigned int width, + unsigned int height) { + min_width_ = width; + min_height_ = height; +} + +void Win32Window::PerformHapticFeedback(HWND const window, int feedback_type) { + // Windows 无原生触觉反馈,使用 MessageBeep 模拟 + // feedback_type: 0=light, 1=medium, 2=heavy, 3=selection + UINT beep_type = MB_OK; // 默认 + switch (feedback_type) { + case 0: // light + beep_type = 0xFFFFFFFF; // 简单蜂鸣 + break; + case 1: // medium + beep_type = MB_ICONINFORMATION; + break; + case 2: // heavy + beep_type = MB_ICONWARNING; + break; + case 3: // selection + beep_type = 0xFFFFFFFF; + break; + default: + break; + } + MessageBeep(beep_type); +} + +std::string Win32Window::GetSystemAppearance() { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, + kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS && light_mode == 0) { + return "dark"; + } + return "light"; +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h index 34dc4ceb..e7541e48 100644 --- a/windows/runner/win32_window.h +++ b/windows/runner/win32_window.h @@ -58,6 +58,31 @@ class Win32Window { // Set the window frame's dark mode explicitly (called from Flutter). static void SetDarkMode(HWND const window, bool dark_mode); + // ============================================================ + // 窗口管理扩展方法(由 Flutter 侧通过 MethodChannel 调用) + // ============================================================ + + // 设置窗口标题 + static void SetWindowTitle(HWND const window, const std::wstring& title); + + // 进入/退出全屏模式 + // fullscreen=true: 保存当前窗口状态,移除标题栏和边框,最大化窗口 + // fullscreen=false: 恢复保存的窗口状态 + static void SetFullscreen(HWND const window, bool fullscreen); + + // 查询当前是否处于全屏模式 + static bool IsFullscreen(HWND const window); + + // 设置窗口最小尺寸(物理像素) + static void SetMinSize(HWND const window, unsigned int width, + unsigned int height); + + // 执行触觉反馈(Windows 使用 MessageBeep 模拟) + static void PerformHapticFeedback(HWND const window, int feedback_type); + + // 获取系统外观模式("light" 或 "dark") + static std::string GetSystemAppearance(); + protected: // Processes and route salient window messages for mouse handling, // size change and DPI. Delegates handling of these to member overloads that @@ -100,6 +125,21 @@ class Win32Window { // window handle for hosted content. HWND child_content_ = nullptr; + + // ============================================================ + // 窗口管理扩展状态 + // ============================================================ + + // 全屏状态保存 + static bool fullscreen_; // 是否处于全屏模式 + static RECT saved_window_rect_; // 全屏前保存的窗口位置 + static LONG saved_window_style_; // 全屏前保存的窗口样式 + static LONG saved_window_ex_style_; // 全屏前保存的扩展样式 + static WINDOWPLACEMENT saved_placement_; // 全屏前保存的窗口位置信息 + + // 最小尺寸限制(物理像素) + static unsigned int min_width_; + static unsigned int min_height_; }; #endif // RUNNER_WIN32_WINDOW_H_