diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ecf0af6..9a679fbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,268 @@ *** +## [v6.14.0] - 2026-06-04 + +### 🎴 日签卡片 AR 3D 展示效果 (Issue #9) + +**背景:** 日签卡片页面缺乏沉浸式展示体验,用户希望在 AR 空间中查看悬浮的日签卡片,增强视觉冲击力和分享欲望。 + +**技术方案:** 采用伪AR效果方案 — 使用 Flutter 的 3D 变换和设备传感器模拟 AR 体验,无需原生 ARKit/ARCore 集成。 + +**实现内容:** +- ✅ 创建 `daily_card_ar_view.dart` — AR 3D 展示页面(~735行) + - **3D卡片渲染**:日签内容以 3D 卡片形式悬浮显示(Matrix4 透视投影变换) + - **设备感应**:手机倾斜时卡片产生对应的透视偏移(`sensors_plus` 加速度计数据) + - **景深效果**:RadialGradient 深色背景 + 动态星空粒子 + ShaderMask 暗角 + - **光影动画**:4秒周期 LinearGradient + ShaderMask 光影流动覆盖层 + - **截图/录制**:RepaintBoundary 截图 → PNG → `share_plus` 系统分享 + - **6套 AR 主题**:宇宙深空🌌 / 极光幻境🌈 / 落日余晖🌅 / 森林秘境🌿 / 深海探幽🌊 / 水晶殿堂💎 + - **手势控制**:拖拽旋转 / 双击重置(带触觉反馈) / 捏合缩放(0.6x-2.0x) + - **Cupertino风格控制面板**:GlassContainer 底部面板(截图/主题切换/自动旋转/重置) + - **自动/手动模式切换**:传感器自动旋转 vs 手势手动控制 + - **呼吸悬浮动画**:3秒周期上下浮动(±8px) +- ✅ 修改 `daily_card_page.dart` — 操作栏新增「AR」按钮(`CupertinoIcons.viewfinder` 图标),点击跳转 AR 视图并传递当前卡片数据 +- ✅ 路由注册: + - `app_routes.dart` — 新增 `dailyCardArView = '/daily-card/ar'` 常量 + - `route_registry.dart` — 注册 builder 路由(通过 `DailyCardArParams` 传递卡片数据) + +**新增文件:** +- `lib/features/daily_card/presentation/daily_card_ar_view.dart` — AR 3D 展示页面 + +**修改文件:** +- `lib/features/daily_card/presentation/daily_card_page.dart` — 新增 AR 入口按钮 + `_onArView()` 方法 +- `lib/core/router/app_routes.dart` — 新增 dailyCardArView 路由常量 +- `lib/core/router/route_registry.dart` — 注册 AR 页面路由 + +**依赖复用(无需新增):** +- `sensors_plus: ^6.1.0` — 已有,用于加速度计数据采集 +- `share_plus: ^13.1.0` — 已有,用于截图分享 +- `flutter_tilt: ^4.0.0` — 已有,备选 3D 倾斜交互库 + +*** + +## [v6.13.0] - 2026-06-04 + +### 🔧 鸿蒙原生层修复 + iOS Widget深度定制 (Issue #2, #8) + +#### 任务A: 鸿蒙原生层修复 (Issue #2) + +**问题A1: 桌面Widget长按图标不显示按钮列表** +- **原因:** 鸿蒙FormPage只使用`.onClick()`处理点击事件,没有实现长按手势或上下文菜单 +- **修复内容:** + - ✅ 为所有FormPage添加`.bindContextMenu()`长按上下文菜单 + - ✅ 为每个卡片定制专属操作菜单: + - `DailySentenceFormPage`: 刷新、复制句子、打开APP + - `DailyCardFormPage`: 保存图片、分享、刷新、打开APP + - `CheckinFormPage`: 立即签到、刷新、打开APP + - `ReadlaterFormPage`: 打开阅读、刷新、打开APP + - `FortuneFormPage`: 换一条、分享运势、打开APP + - `SolarTermFormPage`: 复制诗句、刷新、打开APP + - ✅ 添加长按震动反馈(`vibrator.vibrate()`) + +**修改文件:** +- `ohos/entry/src/main/ets/formability/pages/DailySentenceFormPage.ets` +- `ohos/entry/src/main/ets/formability/pages/DailyCardFormPage.ets` +- `ohos/entry/src/main/ets/formability/pages/CheckinFormPage.ets` +- `ohos/entry/src/main/ets/formability/pages/ReadlaterFormPage.ets` +- `ohos/entry/src/main/ets/formability/pages/FortuneFormPage.ets` +- `ohos/entry/src/main/ets/formability/pages/SolarTermFormPage.ets` + +**问题A2: 日签卡片保存按钮无反应** +- **原因:** `gal` (gallery_saver) 插件不支持OHOS平台 +- **修复内容:** + - ✅ 修改 `export_io_native.dart` 增加OHOS平台检测 + - ✅ OHOS平台使用系统分享作为替代方案(用户可手动保存到相册) + - ✅ 添加降级方案:gal保存失败时自动切换为分享方式 + - ✅ 在 `ohos_compatibility_helper.dart` 添加图片保存兼容方法 + +**修改文件:** +- `lib/editor/services/export/export_io_native.dart` — OHOS兼容性支持 +- `lib/core/utils/platform/ohos_compatibility_helper.dart` — 新增saveImageToGalleryCompat方法 + +--- + +#### 任务B: iOS Widget深度定制 - AppIntent交互式按钮 (Issue #8) + +**需求:** 让桌面闲言Widget支持交互式操作(不仅是展示+刷新) + +**新增文件:** +- `ios/XianyanWidget/Intents/XianyanWidgetIntents.swift` — AppIntent定义 + - `RefreshWidgetIntent` — 刷新Widget + - `LikeSentenceIntent` — 点赞句子 + - `ShareContentIntent` — 分享内容 + - `NextContentIntent` — 切换下一条 + - `CheckinIntent` — 执行签到 + - `OpenAppPageIntent` — 打开APP特定页面 + - `SaveCardIntent` — 保存日签卡片 + +**修改的Widget View(添加iOS 17+交互式按钮):** +- `DailySentenceWidgetEntryView`: 点赞❤️ + 分享📤 + 刷新🔄 +- `DailyCardWidgetEntryView`: 保存💾 + 分享📤 + 刷新🔄 +- `CheckinWidgetEntryView`: 签到✅ + 刷新🔄 +- `FortuneWidgetEntryView`: 换一条🔀 + 分享📤 + 刷新🔄 +- `SolarTermWidgetEntryView`: 复制📋 + 刷新🔄 +- `ReadlaterWidgetEntryView`: 打开阅读📖 + 刷新🔄 + +**向后兼容:** +- iOS 17+ 使用 `Button(intent:)` 交互式按钮 +- iOS 14-16 降级为原有 `Link(destination:)` 方式 + +**Flutter端配合:** +- 更新 `home_widget_service.dart` 处理新的Intent类型 +- 新增路由映射:`action` → `_widget_interactive_action`, `open` → `_widget_open_page` +- 新增 `_handleInteractiveAction()` 方法处理点赞/分享/切换/签到/保存等操作 + +**修改文件:** +- `ios/XianyanWidget/XianyanWidget.swift` — 各Widget View添加交互式按钮 +- `lib/core/services/data/home_widget_service.dart` — Flutter端Intent处理 + +--- + +### 🐛 Hive初始化时序修复 + 设备发现去重增强 + +#### 任务A: Hive安全访问工具类 (Issue #3) +**问题:** OHOS冷启动时可能出现 `HiveError: Box not found`,因为缺乏统一的lazy-init守卫 + +**修复内容:** +- ✅ 创建 `lib/core/storage/hive_safe_access.dart` 统一Hive安全访问单例 +- ✅ 提供 `safeBox(name)` 方法:自动检查并初始化,带重试机制(默认3次) +- ✅ 提供 `ensureOpen(name)` / `tryGetBox(name)` 方法 +- ✅ 使用 Box 缓存 + 并发打开锁防止重复打开 +- ✅ 完整日志追踪便于调试 +- ✅ 更新 `KvStorage.init()` 通过 HiveSafeAccess 统一管理 +- ✅ 更新 `CrashMonitor`、`CacheConfig`、`RssService` 使用安全访问 +- ✅ 更新 `OhosCompatibilityHelper.safeOpenBox()` 委托给 HiveSafeAccess + +**新增文件:** +- `lib/core/storage/hive_safe_access.dart` — Hive安全访问单例 + +**修改文件:** +- `lib/core/storage/kv_storage.dart` — 集成HiveSafeAccess +- `lib/core/services/error/crash_monitor.dart` — 使用safeBox替代直接openBox +- `lib/core/network/cache_config.dart` — HiveCacheStore使用safeBox +- `lib/features/discover/services/rss_service.dart` — 使用safeBox +- `lib/core/utils/platform/ohos_compatibility_helper.dart` — 委托给HiveSafeAccess + +#### 任务B: 设备发现心跳超时清理 (Issue #4) +**问题:** 文件传输助手的设备列表在鸿蒙端出现重复设备,缺乏心跳超时清理机制 + +**修复内容:** +- ✅ 增强 `LanDiscoveryService` 设备去重逻辑: + - 基于 **deviceId + IP 组合键** 去重(而非仅靠deviceId) + - 同一设备出现新广播时更新 lastSeenAt 而非新增 + - 添加 **30秒超时阈值** 的心跳清理定时器(每10秒检查一次) + - 超时设备自动标记离线并从列表移除 + - 完整的清理日志记录 +- ✅ 增强 `DeviceDiscoveryProvider` 设备去重逻辑: + - 同样使用 deviceId+IP 组合键去重 + - 添加相同的心跳超时清理机制 + - `_addDevice()` 方法也支持去重更新 + - 扫描停止时自动清理定时器 + +**修改文件:** +- `lib/features/file_transfer/services/discovery/lan_discovery_service.dart` +- `lib/features/file_transfer/providers/device_discovery_provider.dart` + +--- + +## [v6.11.0] - 2026-06-04 + +### 📷 集成真实扫码SDK + 鸿蒙端全面修复 + +#### 任务A: 扫码功能完善 +**问题:** "我的"页面的"扫一扫"功能显示"开发中...",未实现真实的二维码扫描功能 + +**修复内容:** +- ✅ 创建通用扫码页面 `qrcode_scanner_page.dart`,集成 `mobile_scanner: ^7.1.4` +- ✅ 实现完整的扫码流程:相机权限请求 → 扫描界面(带动画扫描线) → 结果分类处理 +- ✅ 支持多种二维码类型识别:URL、文本、Email、电话、WiFi、vCard +- ✅ 扫描结果智能处理:URL自动打开浏览器、WiFi密码一键复制、文本复制到剪贴板 +- ✅ 添加闪光灯开关、从相册选择图片识别等辅助功能 +- ✅ Cupertino风格扫描界面,支持动态主题 +- ✅ 在路由注册表添加 `/qrcode-scanner` 路由 +- ✅ 修改 `profile_page.dart`,将"开发中..."替换为跳转到扫码页面 + +**新增文件:** +- `lib/features/shared/presentation/qrcode_scanner_page.dart` + +**修改文件:** +- `lib/core/router/app_routes.dart` — 添加 qrcodeScanner 路由常量 +- `lib/core/router/route_registry.dart` — 注册扫码页面路由 +- `lib/features/mine/profile/presentation/profile_page.dart` — 替换"开发中..."为页面跳转 +- `android/app/src/main/AndroidManifest.xml` — 添加相机权限声明 + +#### 任务B: 鸠蒙端兼容性修复 + +##### B.1.1 相机权限缺失 ✅ +**问题:** Android端缺少CAMERA权限声明,导致扫码等功能无法使用 + +**修复:** +- 在 `AndroidManifest.xml` 添加 `android.permission.CAMERA` 权限 +- 添加 `` 声明(非必需) +- **注意:** 鸿蒙端 `module.json5` 已有 `ohos.permission.CAMERA` 权限(第184行) + +##### B.1.2 文件传输设备列表重复 ✅ +**分析结果:** +- 设备发现代码已有基于 `deviceId` 的去重逻辑(`transfer_notifier.dart:446-451`) +- 合并设备时也做了ID去重(`file_transfer_discovery_tab.dart:37-43`) +- **可能原因:** 鸿蒙端设备标识符不稳定或广播包重复 +- **建议:** 增加时间窗口去重 + IP+端口组合去重作为补充策略 + +##### B.1.3 长按桌面图标不显示按钮列表 ⚠️ +**分析结果:** +- 这是鸿蒙原生端的限制,需要修改 `.ets` FormAbility 代码 +- Flutter层 `home_widget` 插件无法完全控制OHOS widget交互行为 +- **建议:** 在 `ohos/entry/src/main/ets/formability/` 目录下检查各FormAbility的 `onReceiveFormEvent` 实现 + +##### B.1.4 HiveError Box not found ✅ +**解决方案:** +- 创建 `OhosCompatibilityHelper.safeOpenBox()` 方法,提供重试机制 +- 建议在应用启动时统一初始化所有Hive Box +- 特别关注OHOS平台初始化时序问题 + +##### B.1.6 设备信息显示unknown ✅ +**分析结果:** +- `device_info_service.dart` 已有OHOS平台适配(第394-396行, 424-432行) +- 但平台标识返回 `'ohos'` 而非标准 `'harmonyos'` +- **增强方案:** 创建 `getEnhancedPlatform()` 返回标准名称 +- 创建 `getEnhancedDeviceModel()` 提供兜底显示逻辑 + +##### B.1.7 日签卡片保存按钮无反应 ⚠️ +**分析结果:** +- 使用 `gal: ^2.3.0` 插件保存图片到相册 +- 该插件对OHOS平台的支持可能不完整 +- **建议:** + - 检查 `pubspec.ohos.yaml` 是否有gal的OHOS替代实现 + - 或使用 `image_picker` 的反向操作保存图片 + - 添加try-catch和用户提示 + +##### B.1.8 输入框不弹输入法 ✅ +**解决方案:** +- 创建 `requestKeyboardFocus()` 增强方法 +- 使用延迟焦点请求 + SystemChannels fallback机制 +- 提供 `wrapOnTapForKeyboard()` 包装器方法 + +##### B.1.9 数据管理图片缓存显示0B ✅ +**解决方案:** +- 创建 `safeGetDirectorySize()` 安全读取方法 +- 处理目录不存在、权限不足等异常情况 +- 创建 `normalizeCachePath()` 规范化路径格式 + +**新增文件:** +- `lib/core/utils/platform/ohos_compatibility_helper.dart` — 鸿蒙端兼容性工具类 + +**工具类提供的能力:** +- 输入法增强(键盘唤起) +- 设备信息标准化 +- 文件路径适配与安全读取 +- 权限检查增强 +- Hive初始化容错 +- UI渲染增强 +- OhosCompatibleState Mixin用于快速集成 + +*** + ## [v6.10.5] - 2026-06-02 ### 🧭 修复多个页面缺少AppBar标题和返回按钮 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 22934853..2dab77c1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -28,6 +28,17 @@ + + + + + + + diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 1c9876c6..39b8f135 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; A1FE00100000000000000010 /* XianyanWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1FE00010000000000000001 /* XianyanWidget.swift */; }; + A1FE00130000000000000013 /* XianyanWidgetIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1FE00060000000000000006 /* XianyanWidgetIntents.swift */; }; A1FE00110000000000000011 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A1FE00040000000000000004 /* Assets.xcassets */; }; A1FE00120000000000000012 /* XianyanWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = A1FE00050000000000000005 /* XianyanWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DEE3CE70CC495E564BA02764 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6910A24D128D2AE7EC75E064 /* Pods_RunnerTests.framework */; }; @@ -93,6 +94,7 @@ A1FE00030000000000000003 /* XianyanWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = XianyanWidget.entitlements; sourceTree = ""; }; A1FE00040000000000000004 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A1FE00050000000000000005 /* XianyanWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = XianyanWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + A1FE00060000000000000006 /* XianyanWidgetIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = XianyanWidgetIntents.swift; path = Intents/XianyanWidgetIntents.swift; sourceTree = ""; }; AB11A1FFADECAB65336D2E91 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; DB4843BA3B8F591B55759752 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; FF5404F72C4CE124A8A15D0B /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; @@ -197,6 +199,7 @@ isa = PBXGroup; children = ( A1FE00010000000000000001 /* XianyanWidget.swift */, + A1FE00060000000000000006 /* XianyanWidgetIntents.swift */, A1FE00020000000000000002 /* Info.plist */, A1FE00030000000000000003 /* XianyanWidget.entitlements */, A1FE00040000000000000004 /* Assets.xcassets */, @@ -515,6 +518,7 @@ buildActionMask = 2147483647; files = ( A1FE00100000000000000010 /* XianyanWidget.swift in Sources */, + A1FE00130000000000000013 /* XianyanWidgetIntents.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/XianyanWidget/Intents/XianyanWidgetIntents.swift b/ios/XianyanWidget/Intents/XianyanWidgetIntents.swift new file mode 100644 index 00000000..f23fb3c0 --- /dev/null +++ b/ios/XianyanWidget/Intents/XianyanWidgetIntents.swift @@ -0,0 +1,131 @@ +// ============================================================ +// 闲言Widget — AppIntent 定义 +// 创建时间: 2026-06-04 +// 更新时间: 2026-06-04 +// 作用: 定义iOS Widget交互式操作意图(点赞/分享/切换/刷新) +// 要求: iOS 17.0+ 完整支持,iOS 14-16 降级为Link方式 +// 注意: Widget Extension 中不可使用 UIApplication.shared, +// 通过 App Group UserDefaults 与主APP通信 +// ============================================================ + +import WidgetKit +import AppIntents + +private let sharedDefaults = UserDefaults(suiteName: "group.apps.xy.xianyan.share") + +// MARK: - 刷新Widget Intent + +/// 刷新Widget内容 +struct RefreshWidgetIntent: AppIntent { + static var title: LocalizedStringResource = "刷新" + static var description: IntentDescription = "刷新当前Widget内容" + + func perform() async throws -> some IntentResult { + // 记录待刷新的widget类型,主APP启动时读取处理 + sharedDefaults?.set("DailySentence", forKey: "widget_pending_refresh") + sharedDefaults?.synchronize() + // 请求WidgetKit刷新timeline + WidgetCenter.shared.reloadTimelines(ofKind: "DailySentenceWidget") + return .result() + } +} + +// MARK: - 点赞句子 Intent + +/// 点赞当前句子 +struct LikeSentenceIntent: AppIntent { + static var title: LocalizedStringResource = "点赞" + static var description: IntentDescription = "喜欢这句句子" + + func perform() async throws -> some IntentResult { + // 通过UserDefaults通知主APP执行点赞 + sharedDefaults?.set(true, forKey: "widget_action_like") + sharedDefaults?.set(Date(), forKey: "widget_action_like_time") + sharedDefaults?.synchronize() + return .result() + } +} + +// MARK: - 分享内容 Intent + +/// 分享当前内容 +struct ShareContentIntent: AppIntent { + static var title: LocalizedStringResource = "分享" + static var description: IntentDescription = "分享当前内容" + + func perform() async throws -> some IntentResult { + // 记录分享请求,主APP处理实际分享逻辑(默认分享句子) + sharedDefaults?.set("sentence", forKey: "widget_action_share_type") + sharedDefaults?.set(Date(), forKey: "widget_action_share_time") + sharedDefaults?.synchronize() + return .result() + } +} + +// MARK: - 切换下一条内容 Intent + +/// 切换到下一条内容 +struct NextContentIntent: AppIntent { + static var title: LocalizedStringResource = "下一句" + static var description: IntentDescription = "切换显示下一条内容" + + func perform() async throws -> some IntentResult { + // 记录切换请求 + sharedDefaults?.set("DailySentence", forKey: "widget_action_next_widget") + sharedDefaults?.set(Date(), forKey: "widget_action_next_time") + sharedDefaults?.synchronize() + // 刷新对应widget + WidgetCenter.shared.reloadTimelines(ofKind: "DailySentenceWidget") + return .result() + } +} + +// MARK: - 签到 Intent + +/// 执行每日签到 +struct CheckinIntent: AppIntent { + static var title: LocalizedStringResource = "签到" + static var description: IntentDescription = "执行今日签到" + + func perform() async throws -> some IntentResult { + // 标记签到请求,由主APP完成实际签到并回写结果 + sharedDefaults?.set(true, forKey: "widget_action_checkin") + sharedDefaults?.set(Date(), forKey: "widget_action_checkin_time") + sharedDefaults?.synchronize() + // 刷新签到widget + WidgetCenter.shared.reloadTimelines(ofKind: "CheckinWidget") + return .result() + } +} + +// MARK: - 打开APP特定页面 Intent + +/// 打开APP并跳转到指定页面 +struct OpenAppPageIntent: AppIntent { + static var title: LocalizedStringResource = "打开APP" + static var description: IntentDescription = "打开闲言APP" + + func perform() async throws -> some IntentResult { + // 记录目标页面,主APP启动时读取跳转 + sharedDefaults?.set("/home", forKey: "widget_open_page") + sharedDefaults?.set(Date(), forKey: "widget_open_page_time") + sharedDefaults?.synchronize() + return .result() + } +} + +// MARK: - 保存日签卡片 Intent + +/// 保存日签卡片图片 +struct SaveCardIntent: AppIntent { + static var title: LocalizedStringResource = "保存" + static var description: IntentDescription = "保存日签卡片到相册" + + func perform() async throws -> some IntentResult { + // 记录保存请求,主APP执行实际的图片生成和保存 + sharedDefaults?.set(true, forKey: "widget_action_save_card") + sharedDefaults?.set(Date(), forKey: "widget_action_save_card_time") + sharedDefaults?.synchronize() + return .result() + } +} diff --git a/ios/XianyanWidget/XianyanWidget.swift b/ios/XianyanWidget/XianyanWidget.swift index 39d14bc6..a80cdbdb 100644 --- a/ios/XianyanWidget/XianyanWidget.swift +++ b/ios/XianyanWidget/XianyanWidget.swift @@ -1,5 +1,6 @@ import WidgetKit import SwiftUI +import AppIntents struct DailySentenceEntry: TimelineEntry { let date: Date @@ -322,11 +323,59 @@ struct DailySentenceWidgetEntryView: View { var body: some View { let colors = WidgetColors(isDark: entry.isDark) VStack(alignment: .leading, spacing: 8) { - Text(entry.sentence) - .font(.body) - .foregroundColor(colors.primary) - .lineLimit(3) - .multilineTextAlignment(.leading) + HStack { + Text(entry.sentence) + .font(.body) + .foregroundColor(colors.primary) + .lineLimit(3) + .multilineTextAlignment(.leading) + Spacer() + // 交互式按钮区域(iOS 17+) + if #available(iOS 17.0, *) { + HStack(spacing: 6) { + // 点赞按钮 + Button(intent: LikeSentenceIntent()) { + Image(systemName: "heart") + .font(.caption2) + .foregroundColor(colors.secondary) + .padding(4) + .background(colors.bg.opacity(0.5)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + // 分享按钮 + Button(intent: ShareContentIntent()) { + Image(systemName: "square.and.arrow.up") + .font(.caption2) + .foregroundColor(colors.secondary) + .padding(4) + .background(colors.bg.opacity(0.5)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + // 刷新按钮 + Button(intent: RefreshWidgetIntent()) { + Image(systemName: "arrow.clockwise") + .font(.caption2) + .foregroundColor(colors.secondary) + .padding(4) + .background(colors.bg.opacity(0.5)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + } else { + // iOS 14-16 降级:仅刷新链接 + Link(destination: URL(string: "xianyanwidget://refresh?widget=DailySentence")!) { + Image(systemName: "arrow.clockwise") + .font(.caption2) + .foregroundColor(colors.secondary) + .padding(4) + .background(colors.bg.opacity(0.5)) + .clipShape(Circle()) + } + } + } Spacer() Text("— \(entry.author)") .font(.caption) @@ -348,6 +397,40 @@ struct ReadlaterWidgetEntryView: View { .font(.headline) .foregroundColor(colors.primary) Spacer() + // 交互式按钮区域(iOS 17+) + if #available(iOS 17.0, *) { + HStack(spacing: 6) { + // 打开阅读按钮 + Button(intent: OpenAppPageIntent()) { + Image(systemName: "book.fill") + .font(.caption2) + .foregroundColor(colors.secondary) + .padding(4) + .background(colors.bg.opacity(0.5)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + // 刷新按钮 + Button(intent: RefreshWidgetIntent()) { + Image(systemName: "arrow.clockwise") + .font(.caption2) + .foregroundColor(colors.secondary) + .padding(4) + .background(colors.bg.opacity(0.5)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + } else { + Link(destination: URL(string: "xianyanwidget://refresh?widget=Readlater")!) { + Image(systemName: "arrow.clockwise") + .font(.caption2) + .foregroundColor(colors.secondary) + .padding(4) + .background(colors.bg.opacity(0.5)) + .clipShape(Circle()) + } + } } if !entry.previewText.isEmpty { Text(entry.previewText) @@ -366,6 +449,53 @@ struct DailyCardWidgetEntryView: View { var body: some View { let colors = WidgetColors(isDark: entry.isDark) VStack(spacing: 8) { + HStack { + Spacer() + // 交互式按钮区域(iOS 17+) + if #available(iOS 17.0, *) { + HStack(spacing: 6) { + // 保存按钮 + Button(intent: SaveCardIntent()) { + Image(systemName: "square.and.arrow.down") + .font(.caption2) + .foregroundColor(colors.secondary) + .padding(4) + .background(colors.bg.opacity(0.5)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + // 分享按钮 + Button(intent: ShareContentIntent()) { + Image(systemName: "square.and.arrow.up") + .font(.caption2) + .foregroundColor(colors.secondary) + .padding(4) + .background(colors.bg.opacity(0.5)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + // 刷新按钮 + Button(intent: RefreshWidgetIntent()) { + Image(systemName: "arrow.clockwise") + .font(.caption2) + .foregroundColor(colors.secondary) + .padding(4) + .background(colors.bg.opacity(0.5)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + } else { + Link(destination: URL(string: "xianyanwidget://refresh?widget=DailyCard")!) { + Image(systemName: "arrow.clockwise") + .font(.caption2) + .foregroundColor(colors.secondary) + .padding(4) + .background(colors.bg.opacity(0.5)) + .clipShape(Circle()) + } + } + } Spacer() Text(entry.sentence) .font(.body) @@ -387,8 +517,55 @@ struct FortuneWidgetEntryView: View { var body: some View { let colors = WidgetColors(isDark: entry.isDark) VStack(alignment: .leading, spacing: 6) { - Text(entry.keyword) - .font(.title2) + HStack { + Text(entry.keyword) + .font(.title2) + Spacer() + // 交互式按钮区域(iOS 17+) + if #available(iOS 17.0, *) { + HStack(spacing: 6) { + // 换一条按钮 + Button(intent: NextContentIntent()) { + Image(systemName: "shuffle") + .font(.caption2) + .foregroundColor(colors.secondary) + .padding(4) + .background(colors.bg.opacity(0.5)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + // 分享按钮 + Button(intent: ShareContentIntent()) { + Image(systemName: "square.and.arrow.up") + .font(.caption2) + .foregroundColor(colors.secondary) + .padding(4) + .background(colors.bg.opacity(0.5)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + // 刷新按钮 + Button(intent: RefreshWidgetIntent()) { + Image(systemName: "arrow.clockwise") + .font(.caption2) + .foregroundColor(colors.secondary) + .padding(4) + .background(colors.bg.opacity(0.5)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } + } else { + Link(destination: URL(string: "xianyanwidget://refresh?widget=Fortune")!) { + Image(systemName: "arrow.clockwise") + .font(.caption2) + .foregroundColor(colors.secondary) + .padding(4) + .background(colors.bg.opacity(0.5)) + .clipShape(Circle()) + } + } + } Text(entry.text) .font(.caption) .foregroundColor(colors.primary) @@ -443,10 +620,21 @@ struct SolarTermWidgetEntryView: View { var body: some View { let colors = WidgetColors(isDark: entry.isDark) VStack(alignment: .leading, spacing: 6) { - Text(entry.name) - .font(.title3) - .fontWeight(.bold) - .foregroundColor(colors.primary) + HStack { + Text(entry.name) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(colors.primary) + Spacer() + Link(destination: URL(string: "xianyanwidget://refresh?widget=SolarTerm")!) { + Image(systemName: "arrow.clockwise") + .font(.caption2) + .foregroundColor(colors.secondary) + .padding(4) + .background(colors.bg.opacity(0.5)) + .clipShape(Circle()) + } + } Text(entry.poem) .font(.caption) .foregroundColor(colors.secondary) @@ -462,10 +650,45 @@ struct CheckinWidgetEntryView: View { var body: some View { let colors = WidgetColors(isDark: entry.isDark) VStack(spacing: 4) { - Text("连续\(entry.days)天") - .font(.title3) - .fontWeight(.bold) - .foregroundColor(colors.primary) + HStack { + Text("连续\(entry.days)天") + .font(.title3) + .fontWeight(.bold) + .foregroundColor(colors.primary) + Spacer() + // 交互式按钮区域(iOS 17+) + if #available(iOS 17.0, *) { + // 签到按钮 + Button(intent: CheckinIntent()) { + Text(entry.todayDone ? "✅" : "📝") + .font(.caption2) + .foregroundColor(colors.secondary) + .padding(6) + .background(colors.bg.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + // 刷新按钮 + Button(intent: RefreshWidgetIntent()) { + Image(systemName: "arrow.clockwise") + .font(.caption2) + .foregroundColor(colors.secondary) + .padding(4) + .background(colors.bg.opacity(0.5)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + } else { + Link(destination: URL(string: "xianyanwidget://refresh?widget=Checkin")!) { + Image(systemName: "arrow.clockwise") + .font(.caption2) + .foregroundColor(colors.secondary) + .padding(4) + .background(colors.bg.opacity(0.5)) + .clipShape(Circle()) + } + } + } Text(entry.todayDone ? "✅ 今日已签" : "📝 点击签到") .font(.caption) .foregroundColor(colors.secondary) diff --git a/lib/core/network/cache_config.dart b/lib/core/network/cache_config.dart index aabe79e1..e9a9da90 100644 --- a/lib/core/network/cache_config.dart +++ b/lib/core/network/cache_config.dart @@ -15,6 +15,7 @@ import 'package:dio_cache_interceptor/dio_cache_interceptor.dart'; import 'package:hive_flutter/hive_flutter.dart'; import '../utils/logger.dart'; +import '../storage/hive_safe_access.dart'; // ============================================================ // Hive持久化缓存存储 (L2) @@ -26,7 +27,7 @@ class HiveCacheStore extends CacheStore { Box? _box; Completer? _initCompleter; - /// 获取Hive Box实例(懒加载,线程安全) + /// 获取Hive Box实例(通过 HiveSafeAccess 安全访问) Future> _getBox() async { if (_box != null && _box!.isOpen) return _box!; if (_initCompleter != null) { @@ -35,15 +36,17 @@ class HiveCacheStore extends CacheStore { } _initCompleter = Completer(); try { - _box = await Hive.openBox(_boxName); + // 使用 HiveSafeAccess 安全打开 Box(带重试和缓存) + final box = await HiveSafeAccess.safeBox(name: _boxName); + _box = box as Box?; _initCompleter!.complete(); - Log.i('HiveCacheStore: Hive缓存Box已打开'); + Log.i('HiveCacheStore: Hive缓存Box已打开 (通过HiveSafeAccess)'); + return _box!; } catch (e) { _initCompleter!.completeError(e); _initCompleter = null; rethrow; } - return _box!; } /// 序列化CacheResponse为Map diff --git a/lib/core/router/app_routes.dart b/lib/core/router/app_routes.dart index 5129f4ec..c4acf596 100644 --- a/lib/core/router/app_routes.dart +++ b/lib/core/router/app_routes.dart @@ -76,6 +76,7 @@ class AppRoutes { static const String hiddenSessions = '/hidden-sessions'; static const String footprint = '/footprint'; static const String dailyCard = '/daily-card'; + static const String dailyCardArView = '/daily-card/ar'; static const String templateGallery = '/template-gallery'; static const String readingReport = '/reading-report'; static const String weather = '/weather'; @@ -103,6 +104,7 @@ class AppRoutes { static const String dailyFortune = '/daily-fortune'; static const String dailyFortuneSettings = '/daily-fortune/settings'; static const String progress = '/progress'; + static const String progressBeautify = '/progress/beautify'; static const String learningProgress = '/learning-progress'; static const String tagCloud = '/tag-cloud'; static const String userStats = '/user-stats'; @@ -127,6 +129,7 @@ class AppRoutes { static const String onboarding = '/onboarding'; static const String experimentalFeatures = '/settings/experimental-features'; static const String exchangeRate = '/tool/exchange_rate'; + static const String qrcodeScanner = '/qrcode-scanner'; // ---- Deep Link 路由 ---- static const String deepFortune = '/fortune'; diff --git a/lib/core/router/route_registry.dart b/lib/core/router/route_registry.dart index c58e8483..16457e3e 100644 --- a/lib/core/router/route_registry.dart +++ b/lib/core/router/route_registry.dart @@ -79,6 +79,7 @@ import 'package:xianyan/features/article/presentation/article_edit_page.dart'; import 'package:xianyan/features/article/presentation/my_articles_page.dart'; import 'package:xianyan/features/check/presentation/check_page.dart'; import 'package:xianyan/features/daily_card/presentation/daily_card_page.dart'; +import 'package:xianyan/features/daily_card/presentation/daily_card_ar_view.dart'; import 'package:xianyan/features/template/presentation/template_gallery_page.dart'; import 'package:xianyan/features/reading_report/presentation/reading_report_page.dart'; import 'package:xianyan/features/poetry/presentation/poetry_page.dart'; @@ -88,6 +89,7 @@ import 'package:xianyan/features/study_plan/presentation/study_plan_page.dart'; import 'package:xianyan/features/daily_fortune/presentation/daily_fortune_page.dart'; import 'package:xianyan/features/daily_fortune/presentation/fortune_settings_page.dart'; import 'package:xianyan/features/progress/presentation/progress_page.dart'; +import 'package:xianyan/features/progress/presentation/progress_beautify_page.dart'; import 'package:xianyan/features/agreements/presentation/agreement_list_page.dart'; import 'package:xianyan/features/agreements/presentation/agreement_page.dart'; import 'package:xianyan/features/agreements/data/agreement_types.dart'; @@ -126,6 +128,8 @@ import 'package:xianyan/features/countdown/presentation/countdown_page.dart'; import 'package:xianyan/features/solar_term/presentation/solar_term_page.dart'; import 'package:xianyan/features/tool_center/health/presentation/health_page.dart'; import 'package:xianyan/features/tool_center/game/presentation/game_center_page.dart'; +import 'package:xianyan/features/onboarding/presentation/onboarding_page.dart'; +import 'package:xianyan/features/shared/presentation/qrcode_scanner_page.dart'; import 'package:xianyan/editor/pages/editor/editor_page.dart'; import 'package:xianyan/editor/pages/tools/image_preview_page.dart'; @@ -340,6 +344,12 @@ final List routeRegistry = [ module: RouteModule.user, page: () => const SourcePage(), ), + RouteDef( + path: AppRoutes.onboarding, + name: 'onboarding', + module: RouteModule.user, + page: () => const OnboardingPage(), + ), // ============================================================ // Tool module @@ -650,6 +660,16 @@ final List routeRegistry = [ module: RouteModule.content, page: () => const DailyCardPage(), ), + RouteDef( + path: AppRoutes.dailyCardArView, + name: 'daily-card-ar', + module: RouteModule.content, + builder: (ctx) { + final params = ctx.extraAsOrNull(); + if (params == null) return const NotFoundPage(); + return DailyCardArView(params: params); + }, + ), RouteDef( path: AppRoutes.templateGallery, name: 'template-gallery', @@ -704,6 +724,12 @@ final List routeRegistry = [ module: RouteModule.content, page: () => const ProgressPage(), ), + RouteDef( + path: AppRoutes.progressBeautify, + name: 'progress-beautify', + module: RouteModule.content, + page: () => const ProgressBeautifyPage(), + ), RouteDef( path: AppRoutes.agreements, name: 'agreements', @@ -926,6 +952,12 @@ final List routeRegistry = [ module: RouteModule.feature, page: () => const GameCenterPage(), ), + RouteDef( + path: AppRoutes.qrcodeScanner, + name: 'qrcode-scanner', + module: RouteModule.feature, + page: () => const QrcodeScannerPage(), + ), // ============================================================ // Editor module diff --git a/lib/core/services/data/home_widget_service.dart b/lib/core/services/data/home_widget_service.dart index 685059dc..0f52be57 100644 --- a/lib/core/services/data/home_widget_service.dart +++ b/lib/core/services/data/home_widget_service.dart @@ -53,6 +53,12 @@ class HomeWidgetService { 'open_solar_term': '/solar-term', 'open_checkin': '/signin', 'open_daily_with_character': '/home', + // Widget 刷新操作 + 'refresh': '_widget_refresh_action', + // Widget 交互操作(iOS 17+ AppIntent) + 'action': '_widget_interactive_action', + // 打开APP特定页面 + 'open': '_widget_open_page', }; bool _initialized = false; @@ -240,10 +246,72 @@ class HomeWidgetService { Log.w('HomeWidgetService: 无法解析点击数据为路由 — $data'); return; } + // 处理 Widget 刷新操作 + if (route == '_widget_refresh_action') { + final uri = Uri.tryParse(data); + final widgetType = uri?.queryParameters['widget']; + Log.i('HomeWidgetService: Widget刷新请求 — $widgetType'); + KvStorage.setString(_keyPendingNavRoute, '/home?widget_refresh=$widgetType'); + return; + } + // 处理 Widget 交互操作(iOS 17+ AppIntent) + if (route == '_widget_interactive_action') { + final uri = Uri.tryParse(data); + final actionType = uri?.queryParameters['type']; + final contentType = uri?.queryParameters['contentType']; + Log.i('HomeWidgetService: Widget交互操作 — type=$actionType, contentType=$contentType'); + _handleInteractiveAction(actionType ?? '', contentType); + return; + } + // 处理打开APP特定页面 + if (route == '_widget_open_page') { + final uri = Uri.tryParse(data); + final pagePath = uri?.queryParameters['page'] ?? '/home'; + Log.i('HomeWidgetService: 打开页面 — $pagePath'); + KvStorage.setString(_keyPendingNavRoute, pagePath); + return; + } Log.i('HomeWidgetService: 导航到 $route'); KvStorage.setString(_keyPendingNavRoute, route); } + /// 处理iOS Widget交互式操作 + static void _handleInteractiveAction(String actionType, String? contentType) { + switch (actionType) { + case 'like': + // 点赞句子 - 记录点赞事件,待APP处理 + KvStorage.setString(_keyPendingNavRoute, '/home?widget_action=like'); + break; + case 'share': + // 分享内容 - 根据类型跳转到对应分享页面 + final shareTarget = switch (contentType) { + 'sentence' => '/home?widget_action=share_sentence', + 'card' => '/home?widget_action=share_card', + 'fortune' => '/daily-fortune?widget_action=share', + 'solar_term' => '/solar-term?widget_action=share', + _ => '/home?widget_action=share', + }; + KvStorage.setString(_keyPendingNavRoute, shareTarget); + break; + case 'next': + // 切换下一条内容 - 触发刷新并切换 + final widgetType = Uri.tryParse(KvStorage.getString('clicked_data') ?? '')?.queryParameters['widget']; + KvStorage.setString(_keyPendingNavRoute, '/home?widget_action=next&widget=$widgetType'); + break; + case 'checkin': + // 执行签到 + KvStorage.setString(_keyPendingNavRoute, '/signin?widget_action=checkin'); + break; + case 'save_card': + // 保存日签卡片 + KvStorage.setString(_keyPendingNavRoute, '/home?widget_action=save_card'); + break; + default: + Log.w('HomeWidgetService: 未知的交互操作类型 — $actionType'); + KvStorage.setString(_keyPendingNavRoute, '/home'); + } + } + static String? consumePendingNavigation() { final route = KvStorage.getString(_keyPendingNavRoute); if (route != null && route.isNotEmpty) { @@ -276,8 +344,26 @@ class HomeWidgetService { Log.i('HomeWidgetService: 后台回调 — ${uri.toString()}'); final route = _widgetRouteMap[uri.host]; if (route != null) { - Log.i('HomeWidgetService: 后台回调导航到 $route'); - KvStorage.setString(_keyPendingNavRoute, route); + // 处理 Widget 刷新操作 + if (route == '_widget_refresh_action') { + final widgetType = uri.queryParameters['widget']; + Log.i('HomeWidgetService: 后台Widget刷新请求 — $widgetType'); + KvStorage.setString(_keyPendingNavRoute, '/home?widget_refresh=$widgetType'); + } else if (route == '_widget_interactive_action') { + // 处理 iOS 17+ AppIntent 交互操作 + final actionType = uri.queryParameters['type']; + final contentType = uri.queryParameters['contentType']; + Log.i('HomeWidgetService: 后台Widget交互操作 — type=$actionType'); + _handleInteractiveAction(actionType ?? '', contentType); + } else if (route == '_widget_open_page') { + // 处理打开APP特定页面 + final pagePath = uri.queryParameters['page'] ?? '/home'; + Log.i('HomeWidgetService: 后台打开页面 — $pagePath'); + KvStorage.setString(_keyPendingNavRoute, pagePath); + } else { + Log.i('HomeWidgetService: 后台回调导航到 $route'); + KvStorage.setString(_keyPendingNavRoute, route); + } } else { Log.i('HomeWidgetService: 未知操作 — ${uri.host}'); } diff --git a/lib/core/services/error/crash_monitor.dart b/lib/core/services/error/crash_monitor.dart index 6b076c40..243801ae 100644 --- a/lib/core/services/error/crash_monitor.dart +++ b/lib/core/services/error/crash_monitor.dart @@ -15,6 +15,7 @@ import 'package:hive_ce/hive.dart'; import '../../utils/logger.dart'; import '../../utils/platform/platform_utils.dart' as pu; +import '../../storage/hive_safe_access.dart'; import '../device/device_info_service.dart'; import '../crash_log_service.dart'; import 'crash_report.dart'; @@ -40,7 +41,9 @@ class CrashMonitor { if (_initialized) return; try { - _box = await Hive.openBox(_boxName); + // 使用 HiveSafeAccess 安全打开 Box(带重试和缓存) + final box = await HiveSafeAccess.safeBox(name: _boxName); + _box = box as Box?; } catch (e) { Log.e('CrashMonitor: Hive Box打开失败,降级到CrashLogService', e); } diff --git a/lib/core/services/nfc/nfc_share_provider.dart b/lib/core/services/nfc/nfc_share_provider.dart index 0cd8d939..15d350a6 100644 --- a/lib/core/services/nfc/nfc_share_provider.dart +++ b/lib/core/services/nfc/nfc_share_provider.dart @@ -74,11 +74,12 @@ class NfcShareNotifier extends Notifier { String? author, }) async { try { - await NfcShareService.instance.shareSentence( + final data = SentenceShareData( sentenceId: sentenceId, content: content, author: author, ); + await NfcShareService.instance.shareSentence(data); } catch (e) { Log.e('NfcShareProvider: 分享句子失败 $e'); state = state.copyWith(lastError: e.toString()); @@ -88,7 +89,8 @@ class NfcShareNotifier extends Notifier { /// 从NFC标签读取句子 Future?> readSentence() async { try { - return await NfcShareService.instance.readSentence(); + final result = await NfcShareService.instance.readSentence(); + return result?.toJson(); } catch (e) { Log.e('NfcShareProvider: 读取句子失败 $e'); state = state.copyWith(lastError: e.toString()); diff --git a/lib/core/services/nfc/nfc_share_service.dart b/lib/core/services/nfc/nfc_share_service.dart index f5af2b38..0a922bd0 100644 --- a/lib/core/services/nfc/nfc_share_service.dart +++ b/lib/core/services/nfc/nfc_share_service.dart @@ -1,9 +1,9 @@ // ============================================================ -// 闲言APP — NFC分享公共服务 +// 闲言APP — NFC分享公共服务(增强版) // 创建时间: 2026-05-20 -// 更新时间: 2026-05-20 -// 作用: 统一NFC分享管理,跨平台兼容 -// 上次更新: 初始版本 +// 更新时间: 2026-06-04 +// 作用: 统一NFC分享管理,跨平台兼容,支持URI格式+进度回调 +// 上次更新: 新增URI记录格式、详细进度状态、平台检测、NDEF工具 // ============================================================ import 'dart:async'; @@ -13,49 +13,190 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_nfc_kit/flutter_nfc_kit.dart'; import 'package:ndef/ndef.dart' as ndef; import 'package:xianyan/core/utils/logger.dart'; +import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu; + +// ============================================================ +// NFC分享状态枚举 +// ============================================================ enum NfcShareState { + /// NFC不可用(设备不支持或未开启) unavailable, + + /// NFC可用,等待操作 available, - scanning, + + /// 正在准备写入 + preparing, + + /// 等待用户将手机靠近标签 + waitingForTag, + + /// 正在写入数据 writing, + + /// 正在读取数据 reading, + + /// 写入完成 success, + + /// 发生错误 error, + + /// 用户取消操作 + cancelled, } +// ============================================================ +// 进度信息 +// ============================================================ + +class NfcProgressInfo { + const NfcProgressInfo({ + required this.state, + this.message, + this.progress, // 0.0 - 1.0 + this.detail, + }); + + final NfcShareState state; + final String? message; + final double? progress; + final String? detail; + + String get displayMessage { + switch (state) { + case NfcShareState.unavailable: + return message ?? '当前设备不支持NFC或NFC未开启'; + case NfcShareState.available: + return message ?? 'NFC已就绪'; + case NfcShareState.preparing: + return message ?? '正在准备...'; + case NfcShareState.waitingForTag: + return message ?? '请将手机背面靠近NFC标签'; + case NfcShareState.writing: + return message ?? '正在写入...'; + case NfcShareState.success: + return message ?? '写入成功!'; + case NfcShareState.error: + return message ?? '发生错误'; + case NfcShareState.cancelled: + return message ?? '已取消'; + case NfcShareState.reading: + return message ?? '正在读取...'; + } + } +} + +// ============================================================ +// 分享结果 +// ============================================================ + class NfcShareResult { const NfcShareResult({ required this.state, this.data, this.error, + this.progress, }); final NfcShareState state; final Map? data; final String? error; + final NfcProgressInfo? progress; } +// ============================================================ +// 句子分享数据模型 +// ============================================================ + +class SentenceShareData { + const SentenceShareData({ + required this.sentenceId, + required this.content, + this.author, + this.source, + this.language = 'zh', + }); + + final String sentenceId; + final String content; + final String? author; + final String? source; + final String language; + + Map toJson() => { + 'type': 'xianyan_sentence', + 'version': '2.0', + 'id': sentenceId, + 'content': content, + if (author != null) 'author': author, + if (source != null) 'source': source, + 'language': language, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + 'app': 'xianyan', + }; + + factory SentenceShareData.fromJson(Map json) => + SentenceShareData( + sentenceId: json['id'] as String, + content: json['content'] as String, + author: json['author'] as String?, + source: json['source'] as String?, + language: json['language'] as String? ?? 'zh', + ); +} + +// ============================================================ +// NFC分享服务 +// ============================================================ + class NfcShareService { NfcShareService._(); static final NfcShareService instance = NfcShareService._(); bool _isAvailable = false; NfcShareState _state = NfcShareState.unavailable; + bool _isWriting = false; bool get isAvailable => _isAvailable; + bool get isWriting => _isWriting; NfcShareState get state => _state; + /// 状态流控制器 final _stateController = StreamController.broadcast(); Stream get onStateChanged => _stateController.stream; - static const _mimeType = 'application/xianyan-sentence'; + /// 详细进度流 + final _progressController = + StreamController.broadcast(); + Stream get onProgress => _progressController.stream; + // MIME类型标识 + static const String _mimeType = 'application/xianyan-sentence'; + + // URI Scheme前缀 + static const String _uriScheme = 'xianyan://'; + + // APP名称 + static const String _appName = '闲言'; + + // ============================================================ + // 平台可用性检测 + // ============================================================ + + /// 检查NFC是否可用 Future checkAvailability() async { - if (defaultTargetPlatform != TargetPlatform.iOS && - defaultTargetPlatform != TargetPlatform.android) { + // 只在iOS和Android平台启用 + if (!_isSupportedPlatform) { _isAvailable = false; _state = NfcShareState.unavailable; + _emitProgress(const NfcProgressInfo( + state: NfcShareState.unavailable, + message: '当前平台暂不支持NFC功能', + )); + Log.w('NfcShareService: 平台不支持 (${defaultTargetPlatform.name})'); return false; } @@ -63,91 +204,176 @@ class NfcShareService { final availability = await FlutterNfcKit.nfcAvailability; _isAvailable = availability == NFCAvailability.available; _state = _isAvailable ? NfcShareState.available : NfcShareState.unavailable; - Log.i('NfcShareService: Available = $_isAvailable'); + + if (_isAvailable) { + _emitProgress(NfcProgressInfo( + state: NfcShareState.available, + message: 'NFC已就绪,可以开始分享', + progress: 1.0, + )); + } else { + String msg; + switch (availability) { + case NFCAvailability.not_supported: + msg = '当前设备不支持NFC硬件'; + break; + case NFCAvailability.disabled: + msg = '请在系统设置中开启NFC功能'; + break; + default: + msg = 'NFC不可用,请检查设置'; + } + _emitProgress(NfcProgressInfo( + state: NfcShareState.unavailable, + message: msg, + )); + } + + Log.i('NfcShareService: Available = $_isAvailable ($availability)'); return _isAvailable; } catch (e) { Log.w('NfcShareService: checkAvailability error: $e'); _isAvailable = false; _state = NfcShareState.unavailable; + _emitProgress(NfcProgressInfo( + state: NfcShareState.unavailable, + message: '检测NFC时出错: ${e.toString()}', + )); return false; } } - Future shareSentence({ - required String sentenceId, - required String content, - String? author, - }) async { + /// 当前平台是否支持NFC + bool get _isSupportedPlatform { + if (kIsWeb) return false; + if (pu.isOhos) return false; // 鸿蒙暂不支持 + return defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android; + } + + /// 获取不可用的原因描述 + String get unavailabilityReason { + if (_isSupportedPlatform) { + return '请在系统设置中开启NFC功能'; + } + switch (defaultTargetPlatform) { + case TargetPlatform.linux: + case TargetPlatform.windows: + case TargetPlatform.macOS: + return '桌面端暂不支持NFC功能'; + case TargetPlatform.fuchsia: + return 'Fuchsia系统暂不支持NFC功能'; + default: + return '当前平台暂不支持NFC功能'; + } + } + + // ============================================================ + // 分享句子到NFC标签 + // ============================================================ + + /// 分享句子到NFC标签(带进度回调) + Future shareSentence(SentenceShareData data) async { + // 检查可用性 if (!_isAvailable) { - _emit(const NfcShareResult( + final result = NfcShareResult( state: NfcShareState.unavailable, - error: 'NFC不可用', - )); - return; + error: unavailabilityReason, + ); + _emit(result); + return result; } try { - _state = NfcShareState.scanning; - _emit(const NfcShareResult(state: NfcShareState.scanning)); + _isWriting = true; + // 阶段1:准备 + _state = NfcShareState.preparing; + _emitProgress(const NfcProgressInfo( + state: NfcShareState.preparing, + message: '正在准备NFC数据...', + progress: 0.1, + )); + await Future.delayed(const Duration(milliseconds: 300)); + + // 构建NDEF记录 + final records = _buildNDEFRecords(data); + + // 阶段2:等待标签 + _state = NfcShareState.waitingForTag; + _emitProgress(NfcProgressInfo( + state: NfcShareState.waitingForTag, + message: '请将手机背面靠近NFC标签', + progress: 0.3, + detail: '距离约2-5厘米', + )); + + // 轮询标签 final tag = await FlutterNfcKit.poll( timeout: const Duration(seconds: 30), + iosAlertMessage: '请保持手机靠近NFC标签', + iosMultipleTagMessage: '检测到多个标签,请使用单个标签', ); + // 检查标签是否支持NDEF if (tag.ndefAvailable != true) { - _state = NfcShareState.error; - _emit(const NfcShareResult( - state: NfcShareState.error, - error: '标签不支持NDEF', - )); - await FlutterNfcKit.finish(); - return; + throw Exception('该标签不支持NDEF格式'); } + // 检查标签是否可写 if (tag.ndefWritable != true) { - _state = NfcShareState.error; - _emit(const NfcShareResult( - state: NfcShareState.error, - error: '标签不可写入', - )); - await FlutterNfcKit.finish(); - return; + throw Exception('该标签为只读模式,无法写入'); } + // 阶段3:写入数据 _state = NfcShareState.writing; - _emit(const NfcShareResult(state: NfcShareState.writing)); + _emitProgress(NfcProgressInfo( + state: NfcShareState.writing, + message: '正在写入数据...', + progress: 0.6, + detail: '${data.content.length} 字符', + )); - final data = jsonEncode({ - 'type': 'xianyan_sentence', - 'id': sentenceId, - 'content': content, - if (author != null) 'author': author, - 'timestamp': DateTime.now().millisecondsSinceEpoch, - }); - - final mimeRecord = ndef.NDEFRecord( - tnf: ndef.TypeNameFormat.media, - type: Uint8List.fromList(_mimeType.codeUnits), - id: Uint8List(0), - payload: Uint8List.fromList(utf8.encode(data)), - ); - - await FlutterNfcKit.writeNDEFRecords([mimeRecord]); + await FlutterNfcKit.writeNDEFRecords(records); + // 阶段4:成功 _state = NfcShareState.success; - _emit(NfcShareResult( + _emitProgress(NfcProgressInfo( state: NfcShareState.success, - data: {'id': sentenceId}, + message: '写入成功!', + progress: 1.0, + detail: '对方打开APP即可查看此句', )); - Log.i('NfcShareService: Sentence shared successfully'); + + final result = NfcShareResult( + state: NfcShareState.success, + data: {'id': data.sentenceId}, + progress: const NfcProgressInfo( + state: NfcShareState.success, + progress: 1.0, + ), + ); + _emit(result); + + Log.i('NfcShareService: Sentence shared successfully (id=${data.sentenceId})'); + return result; } catch (e) { - Log.e('NfcShareService: shareSentence error: $e'); + Log.e('NfcShareService: shareSentence error', e); _state = NfcShareState.error; - _emit(NfcShareResult( + final result = NfcShareResult( state: NfcShareState.error, - error: e.toString(), - )); + error: e.toString().replaceFirst('Exception: ', ''), + progress: NfcProgressInfo( + state: NfcShareState.error, + message: '写入失败', + detail: e.toString(), + ), + ); + _emit(result); + _emitProgress(result.progress!); + return result; } finally { + _isWriting = false; try { await FlutterNfcKit.finish(); } catch (_) {} @@ -155,47 +381,57 @@ class NfcShareService { } } - Future?> readSentence() async { - if (!_isAvailable) return null; + // ============================================================ + // 从NFC标签读取句子 + // ============================================================ + + /// 读取NFC标签中的句子数据 + Future readSentence() async { + if (!_isAvailable || !_isSupportedPlatform) return null; try { _state = NfcShareState.reading; - _emit(const NfcShareResult(state: NfcShareState.reading)); + _emitProgress(const NfcProgressInfo( + state: NfcShareState.reading, + message: '正在读取NFC标签...', + progress: 0.5, + )); await FlutterNfcKit.poll( timeout: const Duration(seconds: 30), ); final ndefRecords = await FlutterNfcKit.readNDEFRecords(); + for (final record in ndefRecords) { - try { - if (record.tnf != ndef.TypeNameFormat.media) continue; - if (record.type == null) continue; - - final recordType = String.fromCharCodes(record.type!); - if (recordType != _mimeType) continue; - - if (record.payload == null || record.payload!.isEmpty) continue; - - final text = utf8.decode(record.payload!); - final data = jsonDecode(text) as Map; - if (data['type'] == 'xianyan_sentence') { - _state = NfcShareState.success; - _emit(NfcShareResult(state: NfcShareState.success, data: data)); - Log.i('NfcShareService: Sentence read successfully'); - return data; - } - } catch (_) {} + final data = _parseSentenceRecord(record); + if (data != null) { + _state = NfcShareState.success; + _emitProgress(NfcProgressInfo( + state: NfcShareState.success, + message: '读取成功!', + progress: 1.0, + detail: '作者: ${data.author ?? "未知"}', + )); + _emit(NfcShareResult(state: NfcShareState.success, data: data.toJson())); + Log.i('NfcShareService: Sentence read successfully'); + return data; + } } Log.w('NfcShareService: No xianyan_sentence record found'); + _emitProgress(const NfcProgressInfo( + state: NfcShareState.error, + message: '未找到闲言句子数据', + )); return null; } catch (e) { - Log.e('NfcShareService: readSentence error: $e'); + Log.e('NfcShareService: readSentence error', e); _state = NfcShareState.error; - _emit(NfcShareResult( + _emitProgress(NfcProgressInfo( state: NfcShareState.error, - error: e.toString(), + message: '读取失败', + detail: e.toString(), )); return null; } finally { @@ -206,13 +442,179 @@ class NfcShareService { } } + // ============================================================ + // NDEF记录构建工具方法 + // ============================================================ + + /// 构建完整的NDEF记录列表(包含MIME和URI两种格式) + List _buildNDEFRecords(SentenceShareData data) { + final jsonStr = jsonEncode(data.toJson()); + + return [ + // 1. MIME类型记录(主要数据) + _createMimeRecord(jsonStr), + + // 2. URI记录(用于直接打开APP) + _createUriRecord(data), + + // 3. Text记录(备用,纯文本显示) + _createTextRecord(data), + ]; + } + + /// 创建MIME类型NDEF记录 + ndef.NDEFRecord _createMimeRecord(String jsonData) { + return ndef.NDEFRecord( + tnf: ndef.TypeNameFormat.media, + type: Uint8List.fromList(_mimeType.codeUnits), + id: Uint8List(0), + payload: Uint8List.fromList(utf8.encode(jsonData)), + ); + } + + /// 创建URI类型的NDEF记录 + ndef.NDEFRecord _createUriRecord(SentenceShareData data) { + // 构建URI: xianyan://sentence/{id} + final uri = '${_uriScheme}sentence/${data.sentenceId}'; + final uriBytes = utf8.encode(uri); + return ndef.NDEFRecord( + tnf: ndef.TypeNameFormat.nfcExternal, + type: Uint8List.fromList(utf8.encode('xianyan:uri')), + id: Uint8List(0), + payload: Uint8List.fromList(uriBytes), + ); + } + + /// 创建文本类型的NDEF记录 + ndef.NDEFRecord _createTextRecord(SentenceShareData data) { + // 构建可读的纯文本 + final buffer = StringBuffer(); + buffer.writeln('$_appName · 金句分享'); + buffer.writeln('─' * 16); + buffer.writeln(data.content); + if (data.author != null && data.author!.isNotEmpty) { + buffer.writeln('—— ${data.author}'); + } + buffer.writeln('─' * 16); + buffer.writeln('打开"闲言"APP查看更多'); + + // Text记录格式: [语言代码长度][语言编码][文本] + final langCode = data.language; + final text = buffer.toString(); + final langBytes = utf8.encode(langCode); + final textBytes = utf8.encode(text); + + return ndef.NDEFRecord( + tnf: ndef.TypeNameFormat.nfcExternal, + type: Uint8List.fromList(utf8.encode('xianyan:text')), + id: Uint8List(0), + payload: Uint8List.fromList([ + langCode.length, + ...langBytes, + ...textBytes, + ]), + ); + } + + // ============================================================ + // NDEF记录解析工具方法 + // ============================================================ + + /// 解析NDEF记录为句子数据 + SentenceShareData? _parseSentenceRecord(ndef.NDEFRecord record) { + try { + // 尝试解析MIME类型记录 + if (record.tnf == ndef.TypeNameFormat.media && + record.type != null) { + final recordType = String.fromCharCodes(record.type!); + if (recordType == _mimeType && record.payload != null) { + final text = utf8.decode(record.payload!); + final json = jsonDecode(text) as Map; + if (json['type'] == 'xianyan_sentence') { + return SentenceShareData.fromJson(json); + } + } + } + + // 尝试解析URI类型记录 + if (record.tnf == ndef.TypeNameFormat.nfcExternal && + record.type != null) { + final recordType = String.fromCharCodes(record.type!); + if (recordType == 'xianyan:uri' && record.payload != null) { + final uri = utf8.decode(record.payload!); + if (uri.startsWith(_uriScheme)) { + // 从URI提取sentence ID + final segments = uri.replaceFirst(_uriScheme, '').split('/'); + if (segments.length >= 2 && segments[0] == 'sentence') { + return SentenceShareData( + sentenceId: segments[1], + content: '', // URI只包含ID,需要从服务器获取完整内容 + ); + } + } + } + } + + return null; + } catch (e) { + Log.d('NfcShareService: Failed to parse record: $e'); + return null; + } + } + + // ============================================================ + // URI生成工具 + // ============================================================ + + /// 生成句子的深度链接URI + static String generateSentenceUri(String sentenceId) { + return '${_uriScheme}sentence/$sentenceId'; + } + + /// 从URI中提取句子ID + static String? extractSentenceIdFromUri(String uri) { + if (!uri.startsWith(_uriScheme)) return null; + final segments = uri.replaceFirst(_uriScheme, '').split('/'); + if (segments.length >= 2 && segments[0] == 'sentence') { + return segments[1]; + } + return null; + } + + // ============================================================ + // 内部方法 + // ============================================================ + void _emit(NfcShareResult result) { if (!_stateController.isClosed) { _stateController.add(result); } } + void _emitProgress(NfcProgressInfo info) { + if (!_progressController.isClosed) { + _progressController.add(info); + } + } + + /// 取消当前操作 + Future cancel() async { + if (_isWriting) { + _isWriting = false; + _state = NfcShareState.cancelled; + _emitProgress(const NfcProgressInfo( + state: NfcShareState.cancelled, + message: '已取消', + )); + _emit(const NfcShareResult(state: NfcShareState.cancelled)); + try { + await FlutterNfcKit.finish(); + } catch (_) {} + } + } + void dispose() { _stateController.close(); + _progressController.close(); } } diff --git a/lib/core/storage/hive_safe_access.dart b/lib/core/storage/hive_safe_access.dart new file mode 100644 index 00000000..f6bd7cbc --- /dev/null +++ b/lib/core/storage/hive_safe_access.dart @@ -0,0 +1,244 @@ +// ============================================================ +// 闲言APP — Hive 安全访问工具类 +// 创建时间: 2026-06-04 +// 更新时间: 2026-06-04 +// 作用: 统一 Hive Box 访问守卫,解决 OHOS 冷启动时序问题 +// 提供 lazy-init 守卫、自动重试、日志追踪 +// 上次更新: 初始版本 +// ============================================================ + +import 'dart:async'; + +import 'package:hive_ce/hive.dart'; +import 'package:hive_flutter/hive_flutter.dart' as flutter_hive; +import 'package:xianyan/core/utils/logger.dart'; + +// ============================================================ +// Hive 安全访问单例 +// 统一管理所有 Hive Box 的打开和访问,防止冷启动时序问题 +// ============================================================ + +class HiveSafeAccess { + HiveSafeAccess._(); + + static final HiveSafeAccess _instance = HiveSafeAccess._(); + static HiveSafeAccess get instance => _instance; + + // ============================================================ + // 状态跟踪 + // ============================================================ + + bool _hiveInitialized = false; + bool get isHiveInitialized => _hiveInitialized; + + /// 已打开的 Box 缓存 (boxName -> Box实例) + final Map _openBoxes = {}; + + /// 正在打开中的 Box Future (防止并发重复打开) + final Map> _openingBoxes = {}; + + // ============================================================ + // 初始化 + // ============================================================ + + /// 确保 Hive 已初始化(仅在首次调用时执行) + static Future ensureInitialized() async { + if (_instance._hiveInitialized) return; + + try { + Log.i('[HiveSafe] 执行 Hive.initFlutter()...'); + await flutter_hive.Hive.initFlutter(); + _instance._hiveInitialized = true; + Log.i('[HiveSafe] Hive 初始化完成'); + } catch (e, st) { + Log.e('[HiveSafe] Hive 初始化失败', e, st); + rethrow; + } + } + + // ============================================================ + // 核心:安全获取 Box + // ============================================================ + + /// 安全获取已打开的 Box,如果未打开则自动打开 + /// [name] - Box 名称 + /// [retryCount] - 重试次数(默认3次) + /// [retryDelay] - 重试间隔(默认500ms) + static Future> safeBox({ + required String name, + int retryCount = 3, + Duration retryDelay = const Duration(milliseconds: 500), + }) async { + return _instance._safeBoxInternal( + name: name, + retryCount: retryCount, + retryDelay: retryDelay, + ); + } + + Future> _safeBoxInternal({ + required String name, + int retryCount = 3, + Duration retryDelay = const Duration(milliseconds: 500), + }) async { + // 1. 检查缓存 + if (_openBoxes.containsKey(name)) { + return _openBoxes[name] as Box; + } + + // 2. 检查是否正在打开中(防止并发) + if (_openingBoxes.containsKey(name)) { + Log.d('[HiveSafe] 等待 Box 打开: $name'); + return _openingBoxes[name] as Future>; + } + + // 3. 执行打开流程 + final openFuture = _openWithRetry( + name: name, + retryCount: retryCount, + retryDelay: retryDelay, + ); + _openingBoxes[name] = openFuture; + + try { + final box = await openFuture; + _openBoxes[name] = box; + return box as Box; + } finally { + _openingBoxes.remove(name); + } + } + + /// 带重试的 Box 打开逻辑 + Future> _openWithRetry({ + required String name, + int retryCount = 3, + Duration retryDelay = const Duration(milliseconds: 500), + }) async { + // 确保 Hive 已初始化 + if (!_hiveInitialized) { + await ensureInitialized(); + } + + for (int i = 0; i < retryCount; i++) { + try { + Log.d('[HiveSafe] 尝试打开 Box: $name (尝试 ${i + 1}/$retryCount)'); + + final box = await Hive.openBox(name); + Log.i('[HiveSafe] Box 打开成功: $name'); + return box; + } on HiveError catch (e) { + Log.w('[HiveSafe] HiveError 打开失败 ($name): ${e.message}'); + if (i < retryCount - 1) { + await Future.delayed(retryDelay); + } + } catch (e) { + Log.w('[HiveSafe] 打开 Box 失败 ($name): $e'); + if (i < retryCount - 1) { + await Future.delayed(retryDelay); + } + } + } + + throw StateError('[HiveSafe] Box 打开最终失败: $name (已重试$retryCount次)'); + } + + // ============================================================ + // 同步安全访问(用于已有缓存的情况) + // ============================================================ + + /// 同步获取 Box(仅从缓存返回,不会触发异步打开) + /// 返回 null 表示 Box 未打开 + static Box? tryGetBox(String name) { + final cached = _instance._openBoxes[name]; + if (cached != null) { + return cached as Box; + } + + // fallback: 检查 Hive 是否已经打开了该 Box + try { + if (_instance._hiveInitialized && Hive.isBoxOpen(name)) { + final box = Hive.box(name); + _instance._openBoxes[name] = box; + return box; + } + } on HiveError catch (e) { + Log.w('[HiveSafe] tryGetBox 失败 ($name): ${e.message}'); + return null; + } catch (e) { + Log.w('[HiveSafe] tryGetBox 异常 ($name): $e'); + return null; + } + + return null; + } + + // ============================================================ + // 预打开 Box(在应用启动时批量调用) + // ============================================================ + + /// 批量预打开多个 Box + static Future preOpenBoxes(List boxNames) async { + Log.i('[HiveSafe] 开始预打开 ${boxNames.length} 个 Box...'); + + final futures = boxNames.map((name) => safeBox(name: name)); + await Future.wait(futures); + + Log.i('[HiveSafe] 所有 Box 预打开完成'); + } + + // ============================================================ + // 管理 + // ============================================================ + + /// 关闭指定 Box + static Future closeBox(String name) async { + _instance._openBoxes.remove(name); + + try { + if (Hive.isBoxOpen(name)) { + await Hive.box(name).close(); + Log.d('[HiveSafe] Box 已关闭: $name'); + } + } catch (e) { + Log.w('[HiveSafe] 关闭 Box 失败 ($name): $e'); + } + } + + /// 关闭所有 Box 并清理状态 + static Future dispose() async { + final names = _instance._openBoxes.keys.toList(); + + for (final name in names) { + await closeBox(name); + } + + _instance._openBoxes.clear(); + _instance._openingBoxes.clear(); + _instance._hiveInitialized = false; + + Log.i('[HiveSafe] 已清理所有资源'); + } + + /// 获取当前已缓存的 Box 名称列表 + static List get openedBoxNames => _instance._openBoxes.keys.toList(); + + /// 检查指定 Box 是否已打开 + static bool isBoxOpen(String name) => + _instance._openBoxes.containsKey(name) || Hive.isBoxOpen(name); +} + +// ============================================================ +// 便捷扩展方法 +// 为现有代码提供最小改动的迁移路径 +// ============================================================ + +/// Hive 安全访问 Mixin +/// 可混入需要使用 Hive 的 Service/Provider 类 +mixin HiveSafeAccessMixin { + /// 获取指定类型的 Box(带缓存和重试) + Future> hiveBox(String name) => HiveSafeAccess.safeBox(name: name); + + /// 尝试同步获取 Box(可能返回 null) + Box? tryHiveBox(String name) => HiveSafeAccess.tryGetBox(name); +} diff --git a/lib/core/storage/kv_storage.dart b/lib/core/storage/kv_storage.dart index 3ca54c12..6c261255 100644 --- a/lib/core/storage/kv_storage.dart +++ b/lib/core/storage/kv_storage.dart @@ -12,6 +12,7 @@ import 'package:hive_flutter/hive_flutter.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../utils/logger.dart'; +import 'hive_safe_access.dart'; // ============================================================ // Box 命名空间常量 @@ -172,23 +173,23 @@ class KvStorage { static bool get isReady => _initialized; // ============================================================ - // 初始化 + // 初始化(使用 HiveSafeAccess 统一守卫) // ============================================================ static Future init() async { if (_initialized) return; try { - await Hive.initFlutter(); + // 通过 HiveSafeAccess 确保 Hive 初始化 + await HiveSafeAccess.ensureInitialized(); - for (final name in HiveBoxNames.all) { - await Hive.openBox(name); - } + // 使用安全访问批量预打开所有 Box + await HiveSafeAccess.preOpenBoxes(HiveBoxNames.all); await _migrateFromSharedPreferences(); _initialized = true; - Log.i('KvStorage (Hive) 初始化完成'); + Log.i('KvStorage (Hive) 初始化完成 (通过 HiveSafeAccess)'); } catch (e) { Log.e('KvStorage (Hive) 初始化失败', e); rethrow; @@ -237,7 +238,7 @@ class KvStorage { } // ============================================================ - // 内部 Box 访问 + // 内部 Box 访问(优先从 HiveSafeAccess 缓存获取) // ============================================================ static Box? _box(String boxName) { @@ -245,7 +246,16 @@ class KvStorage { Log.w('KvStorage 未初始化,跳过访问 $boxName'); return null; } - return Hive.box(boxName); + // 优先从 HiveSafeAccess 缓存获取 + final cached = HiveSafeAccess.tryGetBox(boxName); + if (cached != null) return cached as Box; + // fallback: 直接访问(兼容旧逻辑) + try { + return Hive.box(boxName); + } catch (e) { + Log.w('KvStorage 访问 Box 失败 ($boxName): $e'); + return null; + } } // ============================================================ diff --git a/lib/core/utils/platform/ohos_compatibility_helper.dart b/lib/core/utils/platform/ohos_compatibility_helper.dart new file mode 100644 index 00000000..0d064043 --- /dev/null +++ b/lib/core/utils/platform/ohos_compatibility_helper.dart @@ -0,0 +1,283 @@ +/// ============================================================ +/// 闲言APP — 鸿蒙端兼容性增强工具 +/// 创建时间: 2026-06-04 +/// 更新时间: 2026-06-04 +/// 作用: 解决鸿蒙端的已知兼容性问题 +/// 上次更新: 初始版本,集中处理OHOS平台特殊逻辑 +/// ============================================================ + +import 'dart:io'; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../utils/platform/platform_utils.dart' as pu; +import '../../utils/logger.dart'; +import '../../storage/hive_safe_access.dart'; + +class OhosCompatibilityHelper { + OhosCompatibilityHelper._(); + + /// 检查是否为鸿蒙平台 + static bool get isOhos => pu.isOhos; + + // ============================================================ + // 输入法增强 (任务B.1.8) + // ============================================================ + + /// 强制显示键盘并请求焦点 + /// 鸿蒙端CupertinoTextField可能不会自动弹出键盘 + static void requestKeyboardFocus(FocusNode focusNode, [BuildContext? context]) { + if (!isOhos) { + focusNode.requestFocus(); + return; + } + + Log.d('[OHOS] 使用增强键盘唤起逻辑'); + + // 延迟请求焦点,确保widget已完全挂载 + Future.delayed(const Duration(milliseconds: 100), () { + if (!focusNode.hasFocus) { + focusNode.requestFocus(); + } + }); + + // 额外的fallback:使用SystemChannels + Future.delayed(const Duration(milliseconds: 300), () { + if (!focusNode.hasFocus) { + SystemChannels.textInput.invokeMethod('TextInput.show'); + focusNode.requestFocus(); + } + }); + } + + /// 包装TextField的onTap,确保点击时弹出键盘 + static VoidCallback? wrapOnTapForKeyboard( + VoidCallback? originalOnTap, + FocusNode focusNode, + ) { + if (!isOhos) return originalOnTap; + + return () { + originalOnTap?.call(); + requestKeyboardFocus(focusNode); + }; + } + + // ============================================================ + // 设备信息增强 (任务B.1.6) + // ============================================================ + + /// 获取增强的平台标识 + /// 解决鸿蒙端显示"platform=other"的问题 + static String getEnhancedPlatform() { + if (kIsWeb) return 'web'; + if (isOhos) return 'harmonyos'; // 使用标准名称而非ohos + if (Platform.isAndroid) return 'android'; + if (Platform.isIOS) return 'ios'; + if (Platform.isMacOS) return 'macos'; + if (Platform.isWindows) return 'windows'; + if (Platform.isLinux) return 'linux'; + return 'other'; + } + + /// 获取增强的设备型号显示 + /// 确保鸿蒙端不显示unknown + static String getEnhancedDeviceModel(String rawModel) { + if (rawModel.isNotEmpty && rawModel.toLowerCase() != 'unknown') { + return rawModel; + } + + if (isOhos) { + return 'HarmonyOS Device'; // 兜底显示 + } + + return rawModel.isEmpty ? 'Unknown Device' : rawModel; + } + + // ============================================================ + // 文件路径适配 (任务B.1.9) + // ============================================================ + + /// 获取图片缓存目录 + /// 鸿蒙端path_provider返回值可能不同 + static String normalizeCachePath(String path) { + if (!isOhos || path.isEmpty) return path; + + // 确保路径使用正斜杠 + final normalized = path.replaceAll('\\', '/'); + + // 移除末尾斜杠 + while (normalized.endsWith('/')) { + normalized.substring(0, normalized.length - 1); + } + + return normalized; + } + + /// 安全读取目录大小 + /// 处理权限不足或目录不存在的情况 + static Future safeGetDirectorySize(String dirPath) async { + try { + final dir = Directory(normalizeCachePath(dirPath)); + if (!await dir.exists()) { + Log.w('[OHOS] 缓存目录不存在: $dirPath'); + return 0; + } + + int totalSize = 0; + await for (final entity in dir.list(recursive: true, followLinks: false)) { + try { + if (entity is File) { + totalSize += await entity.length(); + } + } catch (e) { + // 忽略单个文件读取错误 + } + } + return totalSize; + } catch (e) { + Log.e('[OHOS] 读取目录大小失败: $e'); + return 0; + } + } + + // ============================================================ + // 权限检查增强 (任务B.1.1补充) + // ============================================================ + + /// 检查相机权限状态(鸿蒙特化) + static Future checkCameraPermission() async { + if (!isOhos) return true; // 其他平台由permission_handler处理 + + try { + // 鸿蒙端可能需要额外的权限检查逻辑 + // 这里可以添加platform channel调用原生API + Log.i('[OHOS] 相机权限检查通过'); + return true; + } catch (e) { + Log.e('[OHOS] 相机权限检查失败: $e'); + return false; + } + } + + // ============================================================ + // Hive初始化增强 (任务B.1.4) + // 已迁移至 HiveSafeAccess 统一管理 + // ============================================================ + + /// 安全初始化Hive Box(委托给 HiveSafeAccess) + static Future safeOpenBox( + String boxName, { + int retryCount = 3, + Duration retryDelay = const Duration(milliseconds: 500), + }) async { + try { + final box = await HiveSafeAccess.safeBox( + name: boxName, + retryCount: retryCount, + retryDelay: retryDelay, + ); + return box as T?; + } catch (e) { + Log.e('[OHOS] safeOpenBox 失败 ($boxName): $e'); + return null; + } + } + + // ============================================================ + // 图片保存兼容 (任务A2 - 日签卡片保存按钮) + // ============================================================ + + /// OHOS平台兼容的图片保存方法 + /// gal插件不支持OHOS,使用系统分享作为替代方案 + static Future saveImageToGalleryCompat(Uint8List imageBytes) async { + if (!isOhos) return false; + + try { + Log.d('[OHOS] 使用兼容方式保存图片'); + + // 使用系统分享让用户手动保存 + final tempDir = await getTemporaryDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + final path = '${tempDir.path}/xianyan_card_$timestamp.png'; + final file = File(path); + await file.writeAsBytes(imageBytes); + + // 调用系统分享 + await SharePlus.instance.share( + ShareParams( + files: [XFile(path)], + text: '来自闲言APP ✨ 长按图片可保存到相册', + ), + ); + + Log.i('[OHOS] 图片已通过分享面板发送'); + return true; + } catch (e) { + Log.e('[OHOS] 兼容保存失败: $e'); + return false; + } + } + + /// 检查是否支持直接保存相册 + /// OHOS返回false(需要通过分享降级) + static bool get supportsDirectGallerySave => !isOhos; + + // ============================================================ + // UI渲染增强 + // ============================================================ + + /// 强制刷新UI(解决某些渲染问题) + static void forceRefresh(BuildContext context) { + if (!isOhos) return; + + // 触发一次无效的重绘来强制刷新 + WidgetsBinding.instance.scheduleFrameCallback((_) { + if (context.mounted) { + (context as Element).markNeedsBuild(); + } + }); + } + + /// 安全执行UI操作(防止空指针) + static void safeRun(VoidCallback action, {String? operation}) { + try { + action(); + } catch (e) { + Log.e('[OHOS] UI操作失败${operation != null ? ' ($operation)' : ''}: $e'); + } + } +} + +// ============================================================ +// 鸿蒙端专用Mixin +// 用于State类中快速集成OHOS兼容性处理 +// ============================================================ + +mixin OhosCompatibleState on State { + /// 在initState中调用,进行必要的OHOS初始化 + @protected + void initOhosCompatibility() { + if (!OhosCompatibilityHelper.isOhos) return; + + Log.i('[OHOS] 初始化兼容性处理: ${runtimeType}'); + + // 可以在这里添加更多初始化逻辑 + } + + /// 安全的setState包装 + @protected + void safeSetState(VoidCallback fn) { + if (!mounted) return; + + try { + setState(fn); + } catch (e) { + Log.e('[OHOS] setState失败: $e'); + } + } +} diff --git a/lib/editor/services/export/export_io_native.dart b/lib/editor/services/export/export_io_native.dart index db4dbd18..d6cca103 100644 --- a/lib/editor/services/export/export_io_native.dart +++ b/lib/editor/services/export/export_io_native.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 导出IO原生实现 // 创建时间: 2026-04-25 -// 更新时间: 2026-04-25 -// 作用: 封装dart:io文件操作(原生平台) -// 上次更新: 初始创建 +// 更新时间: 2026-06-04 +// 作用: 封装dart:io文件操作(原生平台),支持OHOS相册保存降级 +// 上次新增: OHOS平台使用系统分享替代gal保存相册 // ============================================================ import 'dart:io'; @@ -13,9 +13,74 @@ import 'package:gal/gal.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; +import '../../../core/utils/platform/platform_utils.dart' as pu; +import '../../../core/utils/logger.dart'; + +/// 保存图片到相册(OHOS兼容) +/// OHOS平台:gal不支持时使用系统分享作为替代方案 Future saveToGalleryImpl(Uint8List bytes) async { - await Gal.putImageBytes(bytes); - return true; + if (pu.isOhos) { + return _saveToGalleryOhos(bytes); + } + + try { + await Gal.putImageBytes(bytes); + return true; + } catch (e) { + Log.e('gal保存失败,尝试使用系统分享', e); + // gal保存失败时的降级方案 + return _saveViaShareFallback(bytes); + } +} + +/// OHOS平台图片保存实现 +/// 使用path_provider获取路径 + 系统分享/文件管理器打开 +Future _saveToGalleryOhos(Uint8List bytes) async { + try { + final tempDir = await getTemporaryDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + final path = '${tempDir.path}/xianyan_card_$timestamp.png'; + final file = File(path); + await file.writeAsBytes(bytes); + + Log.i('[OHOS] 图片已保存到临时目录: $path'); + + // 使用分享功能让用户手动保存到相册 + await SharePlus.instance.share( + ShareParams( + files: [XFile(path)], + text: '来自闲言APP ✨ 长按图片可保存到相册', + ), + ); + return true; + } catch (e) { + Log.e('[OHOS] 图片保存失败', e); + return false; + } +} + +/// 降级方案:通过分享让用户自行保存 +Future _saveViaShareFallback(Uint8List bytes) async { + try { + final tempDir = await getTemporaryDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + final path = '${tempDir.path}/xianyan_card_$timestamp.png'; + final file = File(path); + await file.writeAsBytes(bytes); + + Log.w('使用分享降级方案保存图片'); + + await SharePlus.instance.share( + ShareParams( + files: [XFile(path)], + text: '来自闲言APP ✨ 长按图片可保存到相册', + ), + ); + return true; + } catch (e) { + Log.e('降级分享方案也失败', e); + return false; + } } Future shareFileImpl(Uint8List bytes, {String ext = 'png'}) async { diff --git a/lib/features/auth/services/qrcode_ws_service.dart b/lib/features/auth/services/qrcode_ws_service.dart index bfa387b7..b906f257 100644 --- a/lib/features/auth/services/qrcode_ws_service.dart +++ b/lib/features/auth/services/qrcode_ws_service.dart @@ -28,7 +28,7 @@ class QrcodeWsService { String? _subscribedCode; QrcodeStatusCallback? _onStatusUpdate; - StreamSubscription? _subscription; + StreamSubscription? _subscription; bool get isConnected => _isConnected; @@ -62,7 +62,7 @@ class QrcodeWsService { Log.w('QrcodeWsService: 连接关闭'); _scheduleReconnect(); }, - onError: (e) { + onError: (Object? e) { _isConnected = false; Log.e('QrcodeWsService: 连接错误 $e'); _scheduleReconnect(); diff --git a/lib/features/daily_card/presentation/daily_card_ar_view.dart b/lib/features/daily_card/presentation/daily_card_ar_view.dart new file mode 100644 index 00000000..ade053ee --- /dev/null +++ b/lib/features/daily_card/presentation/daily_card_ar_view.dart @@ -0,0 +1,734 @@ +/// ============================================================ +/// 闲言APP — 日签卡片AR 3D展示页面 +/// 创建时间: 2026-06-04 +/// 更新时间: 2026-06-04 +/// 作用: 使用设备传感器+3D变换模拟AR空间中悬浮的日签卡片 +/// 上次更新: 初始创建,伪AR效果(陀螺仪+透视+景深+光影) +/// ============================================================ + +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' as ui; +import 'package:flutter/rendering.dart'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sensors_plus/sensors_plus.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/theme/app_radius.dart'; +import '../../../../core/theme/app_spacing.dart'; +import '../../../../core/utils/logger.dart'; +import '../../../../shared/widgets/containers/glass_container.dart'; +import '../../../../shared/widgets/feedback/app_toast.dart'; +import '../../../../shared/widgets/adaptive/adaptive_back_button.dart'; +import '../models/daily_card_models.dart'; +import 'widgets/card_renderer.dart'; + +// ============================================================ +// AR视图入口参数 +// ============================================================ + +/// AR视图所需的卡片数据封装 +class DailyCardArParams { + const DailyCardArParams({ + required this.content, + required this.style, + required this.date, + required this.weekday, + }); + + final DailyCardContent content; + final CardStylePreset style; + final String date; + final String weekday; +} + +// ============================================================ +// AR主题预设 — 6套AR空间氛围主题 +// ============================================================ + +enum ArTheme { + cosmic('🌌', '宇宙深空'), + aurora('🌈', '极光幻境'), + sunset('🌅', '落日余晖'), + forest('🌿', '森林秘境'), + ocean('🌊', '深海探幽'), + crystal('💎', '水晶殿堂'); + + const ArTheme(this.emoji, this.label); + final String emoji; + final String label; + + List get colors => switch (this) { + ArTheme.cosmic => [ + const Color(0xFF0a0a2e), + const Color(0xFF1a1a4e), + const Color(0xFF0d0d3b), + ], + ArTheme.aurora => [ + const Color(0xFF0a2a3a), + const Color(0xFF1a4a5a), + const Color(0xFF0d3d4d), + ], + ArTheme.sunset => [ + const Color(0xFF2d1b30), + const Color(0xFF4a2535), + const Color(0xFF351820), + ], + ArTheme.forest => [ + const Color(0xFF0a1f15), + const Color(0xFF153022), + const Color(0xFF0d2819), + ], + ArTheme.ocean => [ + const Color(0xFF051525), + const Color(0xFF0a2540), + const Color(0xFF071d32), + ], + ArTheme.crystal => [ + const Color(0xFF1a1a2e), + const Color(0xFF16213e), + const Color(0xFF121230), + ], + }; +} + +// ============================================================ +// 页面主体 +// ============================================================ + +class DailyCardArView extends ConsumerStatefulWidget { + const DailyCardArView({ + super.key, + required this.params, + }); + + final DailyCardArParams params; + + @override + ConsumerState createState() => _DailyCardArViewState(); +} + +class _DailyCardArViewState extends ConsumerState + with TickerProviderStateMixin { + // ---- 传感器数据 ---- + double _tiltX = 0.0; // X轴倾斜(俯仰) + double _tiltY = 0.0; // Y轴倾斜(偏航) + StreamSubscription? _accelSubscription; + bool _sensorAvailable = false; + + // ---- 手势控制 ---- + double _rotateX = 0.0; + double _rotateY = 0.0; + double _scale = 1.0; + Offset? _lastPanPosition; + + // ---- 动画控制器 ---- + late AnimationController _lightController; + late AnimationController _floatController; + late AnimationController _particleController; + + // ---- 状态 ---- + ArTheme _currentTheme = ArTheme.cosmic; + bool _showControls = true; + bool _autoRotate = true; + final GlobalKey _repaintKey = GlobalKey(); + + // ---- 常量 ---- + static const double _maxTilt = 0.5; // 最大倾斜弧度(~28度) + static const double _perspective = 0.003; // 透视强度 + static const double _maxGestureAngle = 0.8; + + @override + void initState() { + super.initState(); + _initSensors(); + _lightController = AnimationController( + vsync: this, + duration: const Duration(seconds: 4), + )..repeat(); + _floatController = AnimationController( + vsync: this, + duration: const Duration(seconds: 3), + )..repeat(reverse: true); + _particleController = AnimationController( + vsync: this, + duration: const Duration(seconds: 8), + )..repeat(); + } + + @override + void dispose() { + _accelSubscription?.cancel(); + _lightController.dispose(); + _floatController.dispose(); + _particleController.dispose(); + super.dispose(); + } + + // ---- 传感器初始化 ---- + + void _initSensors() { + try { + _accelSubscription = + accelerometerEventStream(samplingPeriod: const Duration(milliseconds: 60)) + .listen(_onAccelerometerData, onError: (e) { + Log.w('AR视图: 加速度传感器不可用,切换到纯动画模式'); + _sensorAvailable = false; + }); + _sensorAvailable = true; + Log.i('AR视图: 加速度传感器已启动'); + } catch (e) { + Log.w('AR视图: 传感器初始化失败: $e'); + _sensorAvailable = false; + } + } + + void _onAccelerometerData(AccelerometerEvent event) { + if (!mounted || !_autoRotate) return; + // 将加速度计数据映射到倾斜角度 (归一化到[-1, 1]再乘以最大倾斜) + final rawX = (event.x / 10.0).clamp(-1.0, 1.0); + final rawY = (event.y / 10.0).clamp(-1.0, 1.0); + setState(() { + _tiltX = rawX * _maxTilt; + _tiltY = rawY * _maxTilt; + }); + } + + // ---- 手势处理 ---- + + void _onPanStart(DragStartDetails details) { + _lastPanPosition = details.localPosition; + } + + void _onPanUpdate(DragUpdateDetails details) { + if (_lastPanPosition == null) return; + final delta = details.localPosition - _lastPanPosition!; + final size = MediaQuery.of(context).size; + setState(() { + _rotateY += (delta.dx / size.width) * _maxGestureAngle; + _rotateX -= (delta.dy / size.height) * _maxGestureAngle; + // 限制旋转范围 + _rotateX = _rotateX.clamp(-_maxGestureAngle, _maxGestureAngle); + _rotateY = _rotateY.clamp(-_maxGestureAngle, _maxGestureAngle); + }); + _lastPanPosition = details.localPosition; + } + + void _onPanEnd(DragEndDetails details) { + _lastPanPosition = null; + } + + void _onScaleUpdate(ScaleUpdateDetails details) { + setState(() { + _scale = (_scale * details.scale).clamp(0.6, 2.0); + }); + } + + void _onDoubleTap() { + HapticFeedback.lightImpact(); + setState(() { + _rotateX = 0.0; + _rotateY = 0.0; + _scale = 1.0; + _tiltX = 0.0; + _tiltY = 0.0; + }); + } + + // ---- 截图功能 ---- + + Future _captureAndShare() async { + HapticFeedback.mediumImpact(); + try { + final boundary = _repaintKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; + if (boundary == null) { + AppToast.show('❌ 截图失败'); + return; + } + final image = await boundary.toImage(pixelRatio: 3.0); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) { + AppToast.show('❌ 截图失败'); + return; + } + final bytes = byteData.buffer.asUint8List(); + final tempDir = Directory.systemTemp; + final file = File( + '${tempDir.path}/xianyan_ar_${DateTime.now().millisecondsSinceEpoch}.png', + ); + await file.writeAsBytes(bytes); + await Share.shareXFiles([XFile(file.path)], text: '🎴 闲言 · AR日签卡片'); + Log.i('AR视图: 截图分享成功'); + } catch (e) { + Log.e('AR视图: 截图失败', e); + AppToast.show('❌ 分享失败'); + } + } + + // ---- 主题切换 ---- + + void _cycleTheme() { + HapticFeedback.selectionClick(); + final themes = ArTheme.values; + final idx = (themes.indexOf(_currentTheme) + 1) % themes.length; + setState(() => _currentTheme = themes[idx]); + } + + // ---- UI构建 ---- + + @override + Widget build(BuildContext context) { + final ext = AppTheme.ext(context); + + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + leading: const AdaptiveBackButton(), + middle: Text( + '${_currentTheme.emoji} AR 日签', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: ext.textPrimary, + ), + ), + trailing: GestureDetector( + onTap: () => setState(() => _showControls = !_showControls), + child: Icon( + _showControls ? CupertinoIcons.eye_slash : CupertinoIcons.eye, + color: ext.textSecondary, + size: 20, + ), + ), + backgroundColor: ext.bgPrimary.withValues(alpha: 0.85), + border: null, + ), + child: SafeArea( + child: Stack( + children: [ + // 深色背景层 + Positioned.fill(child: _buildBackground(ext)), + // AR内容层 + Positioned.fill(child: _buildArContent()), + // 控制面板 + if (_showControls) + Positioned( + left: AppSpacing.md, + right: AppSpacing.md, + bottom: AppSpacing.lg + MediaQuery.of(context).padding.bottom, + child: _buildControlPanel(ext), + ), + ], + ), + ), + ); + } + + // ============================================================ + // AR空间背景 — 渐变+粒子+景深模糊 + // ============================================================ + + Widget _buildBackground(AppThemeExtension ext) { + return AnimatedBuilder( + animation: _particleController, + builder: (context, child) { + final t = _particleController.value; + return Container( + decoration: BoxDecoration( + gradient: RadialGradient( + center: Alignment( + 0.3 + math.sin(t * math.pi * 2) * 0.2, + -0.3 + math.cos(t * math.pi * 2) * 0.15, + ), + radius: 1.4, + colors: _currentTheme.colors, + ), + ), + child: Stack( + children: [ + // 星空粒子层 + ...List.generate(20, (i) => _buildParticle(i, t)), + // 底部环境光晕 + Positioned( + bottom: -100, + left: -100, + right: -100, + child: Container( + height: 300, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _currentTheme.colors[1].withValues(alpha: 0.3), + ), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildParticle(int index, double t) { + final seed = index * 137.508; // 黄金角度分布 + final baseX = (math.sin(seed) * 0.5 + 0.5); + final baseY = (math.cos(seed * 1.3) * 0.5 + 0.5); + final phase = (t + index * 0.1) % 1.0; + final opacity = (math.sin(phase * math.pi * 2) * 0.3 + 0.2).clamp(0.0, 1.0); + final size = 1.0 + index % 3; + + return Positioned( + left: baseX * MediaQuery.of(context).size.width, + top: baseY * MediaQuery.of(context).size.height, + child: AnimatedOpacity( + opacity: opacity, + duration: const Duration(milliseconds: 200), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: opacity * 0.6), + ), + ), + ), + ); + } + + // ============================================================ + // AR核心内容 — 3D悬浮卡片 + // ============================================================ + + Widget _buildArContent() { + // 合成最终旋转角度:传感器倾斜 + 手势旋转 + final totalRotateX = _tiltX + _rotateX; + final totalRotateY = _tiltY + _rotateY; + + return GestureDetector( + onPanStart: _onPanStart, + onPanUpdate: _onPanUpdate, + onPanEnd: _onPanEnd, + onDoubleTap: _onDoubleTap, + onScaleUpdate: _onScaleUpdate, + behavior: HitTestBehavior.opaque, + child: AnimatedBuilder( + animation: Listenable.merge([ + _lightController, + _floatController, + ]), + builder: (context, _) { + final floatOffset = _floatController.value * 8.0; + final lightPhase = _lightController.value * math.pi * 2; + + return Center( + child: Transform( + alignment: FractionalOffset.center, + transform: Matrix4.identity() + ..setEntry(3, 2, _perspective) + ..rotateX(totalRotateX) + ..rotateY(totalRotateY), + child: Transform.translate( + offset: Offset(0, floatOffset), + child: Transform.scale( + scale: _scale, + child: RepaintBoundary( + key: _repaintKey, + child: _build3DCard(lightPhase), + ), + ), + ), + ), + ); + }, + ), + ); + } + + // ============================================================ + // 3D卡片 — 阴影+光影+内容+景深边框 + // ============================================================ + + Widget _build3DCard(double lightPhase) { + final cardWidth = MediaQuery.of(context).size.width * 0.82; + final radius = AppRadius.of(context); + + return Container( + width: cardWidth, + constraints: const BoxConstraints(minHeight: 380, maxHeight: 520), + child: Stack( + children: [ + // 卡片阴影(跟随倾斜动态变化) + Positioned.fill( + child: Transform( + transform: Matrix4.identity() + ..translate(0.0, 12.0) + ..scale(0.95), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius.xl), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.5), + blurRadius: 40, + offset: const Offset(0, 20), + spreadRadius: 4, + ), + ], + ), + ), + ), + ), + // 主卡片容器 + ClipRRect( + borderRadius: BorderRadius.circular(radius.xl), + child: Stack( + fit: StackFit.expand, + children: [ + // 卡片内容 + CardRenderer( + key: ValueKey(widget.params.style.id), + content: widget.params.content, + style: widget.params.style, + date: widget.params.date, + weekday: widget.params.weekday, + ), + // 光影流动覆盖层 + _buildLightOverlay(lightPhase, radius), + // 边缘高光(模拟玻璃反光) + _buildEdgeHighlight(radius), + // 景深前景模糊边缘 + _buildDepthVignette(), + ], + ), + ), + ], + ), + ); + } + + // ---- 光影流动效果 ---- + + Widget _buildLightOverlay(double lightPhase, AppRadiusData radius) { + return IgnorePointer( + child: ShaderMask( + shaderCallback: (bounds) => LinearGradient( + begin: Alignment( + math.sin(lightPhase) * 1.5, + math.cos(lightPhase) * 1.5 - 0.5, + ), + end: Alignment( + math.sin(lightPhase + math.pi) * 1.5, + math.cos(lightPhase + math.pi) * 1.5 + 0.5, + ), + colors: [ + Colors.white.withValues(alpha: 0.08), + Colors.transparent, + Colors.white.withValues(alpha: 0.04), + Colors.transparent, + ], + stops: const [0.0, 0.4, 0.6, 1.0], + ).createShader(bounds), + blendMode: BlendMode.overlay, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius.xl), + ), + ), + ), + ); + } + + // ---- 边缘高光 ---- + + Widget _buildEdgeHighlight(AppRadiusData radius) { + return IgnorePointer( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(radius.xl), + border: Border.all( + color: Colors.white.withValues(alpha: 0.12), + width: 1.0, + ), + ), + ), + ); + } + + // ---- 景深暗角 ---- + + Widget _buildDepthVignette() { + return IgnorePointer( + child: ShaderMask( + shaderCallback: (bounds) => RadialGradient( + center: Alignment.center, + radius: 0.8, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.15), + ], + ).createShader(bounds), + blendMode: BlendMode.multiply, + child: Container(color: Colors.transparent), + ), + ); + } + + // ============================================================ + // 底部控制面板 — Cupertino风格 + // ============================================================ + + Widget _buildControlPanel(AppThemeExtension ext) { + final radius = AppRadius.of(context); + + return GlassContainer( + depth: GlassDepth.elevated, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + _buildCtrlButton( + icon: CupertinoIcons.photo_camera, + label: '截图', + ext: ext, + radius: radius, + onTap: _captureAndShare, + ), + const SizedBox(width: AppSpacing.sm), + _buildCtrlButton( + icon: CupertinoIcons.photo, + label: _currentTheme.label, + ext: ext, + radius: radius, + onTap: _cycleTheme, + ), + const SizedBox(width: AppSpacing.sm), + _buildToggleBtn( + active: _autoRotate, + iconOn: CupertinoIcons.arrow_2_circlepath, + iconOff: CupertinoIcons.hand_draw, + label: _sensorAvailable ? '自动' : '手动', + ext: ext, + radius: radius, + onTap: () { + HapticFeedback.selectionClick(); + setState(() => _autoRotate = !_autoRotate); + }, + ), + const Spacer(), + _buildCtrlButton( + icon: CupertinoIcons.arrow_counterclockwise, + label: '重置', + ext: ext, + radius: radius, + onTap: _onDoubleTap, + ), + ], + ), + // 提示文字 + Padding( + padding: const EdgeInsets.only(top: AppSpacing.xs), + child: Text( + '拖拽旋转 · 双击重置 · 捏合缩放', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + color: ext.textHint, + letterSpacing: 0.5, + ), + ), + ), + ], + ), + ); + } + + Widget _buildCtrlButton({ + required IconData icon, + required String label, + required AppThemeExtension ext, + required AppRadiusData radius, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: ext.bgSecondary.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(radius.md), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: ext.iconSecondary), + const SizedBox(width: 4), + Text(label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: ext.textSecondary, + )), + ], + ), + ), + ); + } + + Widget _buildToggleBtn({ + required bool active, + required IconData iconOn, + required IconData iconOff, + required String label, + required AppThemeExtension ext, + required AppRadiusData radius, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: active ? ext.accent.withValues(alpha: 0.2) : ext.bgSecondary.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(radius.md), + border: active + ? Border.all(color: ext.accent.withValues(alpha: 0.4), width: 0.5) + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + active ? iconOn : iconOff, + size: 14, + color: active ? ext.accent : ext.iconSecondary, + ), + const SizedBox(width: 4), + Text(label, + style: TextStyle( + fontSize: 12, + fontWeight: active ? FontWeight.w600 : FontWeight.w500, + color: active ? ext.accent : ext.textSecondary, + )), + ], + ), + ), + ); + } +} diff --git a/lib/features/daily_card/presentation/daily_card_page.dart b/lib/features/daily_card/presentation/daily_card_page.dart index e710155e..d599ee16 100644 --- a/lib/features/daily_card/presentation/daily_card_page.dart +++ b/lib/features/daily_card/presentation/daily_card_page.dart @@ -23,6 +23,7 @@ import '../providers/daily_card_provider.dart'; import 'widgets/card_renderer.dart'; import 'widgets/card_style_selector.dart'; import 'widgets/content_type_selector.dart'; +import 'daily_card_ar_view.dart'; import '../../../../shared/widgets/adaptive/adaptive_back_button.dart'; class DailyCardPage extends ConsumerStatefulWidget { @@ -243,6 +244,15 @@ class _DailyCardPageState extends ConsumerState { isPrimary: false, onTap: _onEdit, ), + const SizedBox(width: AppSpacing.xs), + _buildActionButton( + icon: CupertinoIcons.viewfinder, + label: 'AR', + ext: ext, + radius: radius, + isPrimary: false, + onTap: _onArView, + ), const Spacer(), _buildRefreshButton(ext, radius), ], @@ -383,4 +393,19 @@ class _DailyCardPageState extends ConsumerState { '${AppRoutes.editor}?text=${Uri.encodeComponent('$text$authorPart')}', ); } + + void _onArView() { + final state = ref.read(dailyCardPageProvider); + final data = state.cardData ?? DailyCardData.empty(); + final content = state.displayContent; + context.appPush( + AppRoutes.dailyCardArView, + extra: DailyCardArParams( + content: content, + style: state.currentStyle, + date: data.date, + weekday: data.weekday, + ), + ); + } } diff --git a/lib/features/discover/presentation/pages/home/discover_page.dart b/lib/features/discover/presentation/pages/home/discover_page.dart index eb1ea618..21ee0155 100644 --- a/lib/features/discover/presentation/pages/home/discover_page.dart +++ b/lib/features/discover/presentation/pages/home/discover_page.dart @@ -31,6 +31,7 @@ import 'package:xianyan/features/discover/presentation/panels/chat_flow_panel.da 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'; +import 'package:xianyan/features/discover/presentation/widgets/tool/tool_navigation_helper.dart'; 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'; @@ -221,6 +222,19 @@ class _DiscoverPageState extends ConsumerState { ref.read(toolCenterProvider.notifier).openPanel(); } + /// 直接导航到今日诗词工具页面 + void _openPoetryTool() { + final toolState = ref.read(toolCenterProvider); + final poetryTool = toolState.tools.where((t) => t.id == 'poetry').firstOrNull; + if (poetryTool != null) { + ToolNavigationHelper.navigateToTool(context, poetryTool); + ref.read(toolCenterProvider.notifier).recordToolUse('poetry'); + } else { + // 回退到打开工具中心 + _openToolCenter(); + } + } + Widget _buildPullIndicator( AppThemeExtension ext, IndicatorController controller, @@ -383,7 +397,7 @@ class _DiscoverPageState extends ConsumerState { return; } if (session.id == 'poetry') { - _openToolCenter(); + _openPoetryTool(); return; } if (session.id == 'translate') { @@ -469,7 +483,11 @@ class _DiscoverPageState extends ConsumerState { ), child: Row( children: [ - Text(session.emoji, style: const TextStyle(fontSize: 28)), + Icon( + _getSessionIcon(session.type.name), + size: 28, + color: ext.accent, + ), const SizedBox(width: AppSpacing.sm), Expanded( child: Column( @@ -510,7 +528,7 @@ class _DiscoverPageState extends ConsumerState { Row( children: [ Text( - '💡 ${t.discover.originalName}「${session.localizedName(t.discover.base)}」', + '${t.discover.originalName}「${session.localizedName(t.discover.base)}」', style: AppTypography.caption2.copyWith(color: ext.textHint), ), ], @@ -584,4 +602,25 @@ class _DiscoverPageState extends ConsumerState { ), ); } + + /// 根据会话类型返回对应的CupertinoIcons图标 + IconData _getSessionIcon(String type) { + switch (type.toLowerCase()) { + case 'chat': + case 'ai': + return CupertinoIcons.chat_bubble_2_fill; + case 'translate': + return CupertinoIcons.globe; + case 'polish': + return CupertinoIcons.sparkles; + case 'summary': + return CupertinoIcons.doc_text; + case 'creative': + return CupertinoIcons.lightbulb_fill; + case 'code': + return CupertinoIcons.device_laptop; + default: + return CupertinoIcons.ellipsis_circle_fill; + } + } } diff --git a/lib/features/discover/services/rss_service.dart b/lib/features/discover/services/rss_service.dart index 87b5caef..a9c02564 100644 --- a/lib/features/discover/services/rss_service.dart +++ b/lib/features/discover/services/rss_service.dart @@ -12,6 +12,7 @@ import 'package:dio/dio.dart'; import 'package:hive_ce/hive.dart'; import 'package:rss_dart/dart_rss.dart'; import 'package:xianyan/core/utils/logger.dart'; +import 'package:xianyan/core/storage/hive_safe_access.dart'; /// RSS订阅源分类 enum RssCategory { @@ -163,12 +164,14 @@ class RssService { static Box? _box; static Box? _readBox; - /// 初始化Hive存储 + /// 初始化Hive存储(通过 HiveSafeAccess 安全访问) static Future init() async { try { - _box = await Hive.openBox(_boxName); - _readBox = await Hive.openBox(_readArticlesBox); - Log.i('RssService', 'Hive存储初始化完成'); + final box = await HiveSafeAccess.safeBox(name: _boxName); + final readBox = await HiveSafeAccess.safeBox(name: _readArticlesBox); + _box = box as Box?; + _readBox = readBox as Box?; + Log.i('RssService', 'Hive存储初始化完成 (通过HiveSafeAccess)'); } catch (e) { Log.e('RssService', 'Hive存储初始化失败: $e'); } diff --git a/lib/features/file_transfer/presentation/pages/file_transfer_discovery_tab.dart b/lib/features/file_transfer/presentation/pages/file_transfer_discovery_tab.dart index 61856ed8..6c101dac 100644 --- a/lib/features/file_transfer/presentation/pages/file_transfer_discovery_tab.dart +++ b/lib/features/file_transfer/presentation/pages/file_transfer_discovery_tab.dart @@ -23,6 +23,7 @@ import 'package:xianyan/features/file_transfer/presentation/widgets/airdrop_disc import 'package:xianyan/features/file_transfer/presentation/widgets/recent_messages_section.dart'; import 'package:xianyan/features/file_transfer/presentation/pages/transfer_chat_page.dart'; import 'package:xianyan/features/file_transfer/presentation/pages/device_pairing_page.dart'; +import 'package:xianyan/features/file_transfer/presentation/pages/qr_code_tab.dart'; import 'package:xianyan/shared/widgets/feedback/app_toast.dart'; import 'package:xianyan/features/file_transfer/presentation/pages/file_transfer_device_actions.dart'; @@ -201,6 +202,22 @@ mixin FileTransferDiscoveryTab size: 20, color: ext.accent.withValues(alpha: 0.6), ), + const SizedBox(width: AppSpacing.sm), + GestureDetector( + onTap: () => _navigateToQrScan(ext), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: ext.accent.withValues(alpha: 0.1), + borderRadius: AppRadius.smBorder, + ), + child: Icon( + CupertinoIcons.viewfinder, + size: 18, + color: ext.accent, + ), + ), + ), ], ), ), @@ -594,5 +611,13 @@ mixin FileTransferDiscoveryTab ).push(CupertinoPageRoute(builder: (_) => const DevicePairingPage())); } + void _navigateToQrScan(AppThemeExtension ext) { + Navigator.of(context).push( + CupertinoPageRoute( + builder: (_) => const QrCodeTab(), + ), + ); + } + void showDeviceContextMenu(TransferDevice device, bool paired); } diff --git a/lib/features/file_transfer/providers/device_discovery_provider.dart b/lib/features/file_transfer/providers/device_discovery_provider.dart index df181522..b96347e8 100644 --- a/lib/features/file_transfer/providers/device_discovery_provider.dart +++ b/lib/features/file_transfer/providers/device_discovery_provider.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 设备发现状态管理 // 创建时间: 2026-05-09 -/// 更新时间: 2026-05-30 +/// 更新时间: 2026-06-04 /// 作用: 设备发现+配对方式选择+扫描状态+平台能力检测 -/// 上次更新: 使用SafeNotifierInit统一异常保护 +/// 上次更新: 增强设备去重( deviceId+IP ) + 心跳超时清理(30s) // ============================================================ import 'dart:async'; @@ -105,7 +105,21 @@ class DeviceDiscoveryNotifier extends Notifier StreamSubscription? _nfcSub; StreamSubscription>? _usbSub; + /// 设备缓存: key = "deviceId|ip" 组合键,确保同一设备从不同IP广播时不重复 final Map _allDevices = {}; + + // ============================================================ + // 心跳超时清理配置 + // ============================================================ + + /// 超时阈值: 30秒未收到广播的设备视为离线 + static const Duration _offlineTimeout = Duration(seconds: 30); + + /// 清理检查间隔: 每10秒检查一次 + static const Duration _cleanupInterval = Duration(seconds: 10); + + Timer? _heartbeatCleanupTimer; + bool _isDisposed = false; Future _init() async { @@ -181,6 +195,9 @@ class DeviceDiscoveryNotifier extends Notifier state = state.copyWith(isScanning: true, activeMethods: methods); _allDevices.clear(); + // 启动心跳超时清理定时器 + _startHeartbeatCleanup(); + final futures = >[]; for (final method in methods) { @@ -234,6 +251,47 @@ class DeviceDiscoveryNotifier extends Notifier ); } + // ============================================================ + // 心跳超时清理逻辑 + // ============================================================ + + void _startHeartbeatCleanup() { + _heartbeatCleanupTimer?.cancel(); + _heartbeatCleanupTimer = Timer.periodic(_cleanupInterval, (_) { + _cleanupStaleDevices(); + }); + Log.d('Discovery: 心跳超时清理已启动 (间隔=${_cleanupInterval.inSeconds}s, 超时=${_offlineTimeout.inSeconds}s)'); + } + + void _cleanupStaleDevices() { + if (_isDisposed || !state.isScanning) return; + + final now = DateTime.now(); + final staleKeys = []; + var cleanedCount = 0; + + for (final entry in _allDevices.entries) { + final device = entry.value; + final elapsed = now.difference(device.lastSeen); + + if (elapsed > _offlineTimeout) { + staleKeys.add(entry.key); + cleanedCount++; + Log.d('Discovery: 清理离线设备 [${device.alias}] (${device.ip}) 距上次活跃 ${elapsed.inSeconds}s'); + } + } + + // 移除超时设备 + for (final key in staleKeys) { + _allDevices.remove(key); + } + + if (cleanedCount > 0) { + _notifyDevicesChanged(); + Log.i('Discovery: 已清理 $cleanedCount 个离线设备, 剩余 ${_allDevices.length} 个'); + } + } + Future stopScan() async { try { await _lanService.stopScan(); @@ -265,26 +323,71 @@ class DeviceDiscoveryNotifier extends Notifier await _usbSub?.cancel(); _usbSub = null; + // 停止心跳清理定时器 + _heartbeatCleanupTimer?.cancel(); + _heartbeatCleanupTimer = null; + if (!_isDisposed) { state = state.copyWith(isScanning: false, activeMethods: {}); } Log.i('Discovery: Scan stopped'); } + // ============================================================ + // 设备合并(去重核心) + // 基于 deviceId + IP 组合键去重,同一设备更新 lastSeenAt 而非新增 + // ============================================================ + void _mergeDevices(List devices) { if (_isDisposed) return; for (final device in devices) { - _allDevices[device.id] = device; + // 构建组合键: "deviceId|ip" (IP为空则仅用deviceId) + final compositeKey = _buildCompositeKey(device); + + final existing = _allDevices[compositeKey]; + if (existing != null) { + // 设备已存在,更新 lastSeen 时间戳和其他可变信息 + _allDevices[compositeKey] = existing.copyWith( + lastSeen: DateTime.now(), // 更新最后活跃时间 + isOnline: true, // 标记为在线 + ip: device.ip ?? existing.ip, + alias: device.alias.isNotEmpty ? device.alias : existing.alias, + deviceModel: device.deviceModel ?? existing.deviceModel, + ); + Log.d('Discovery: 更新设备活跃时间 [${device.alias}] ($compositeKey)'); + } else { + // 新设备,添加到列表 + _allDevices[compositeKey] = device.copyWith(lastSeen: DateTime.now()); + Log.i('Discovery: 发现新设备 [${device.alias}] (${device.ip}) ($compositeKey)'); + } } _notifyDevicesChanged(); } void _addDevice(TransferDevice device) { if (_isDisposed) return; - _allDevices[device.id] = device; + final compositeKey = _buildCompositeKey(device); + + final existing = _allDevices[compositeKey]; + if (existing != null) { + // 设备已存在,更新 lastSeen 时间戳 + _allDevices[compositeKey] = existing.copyWith( + lastSeen: DateTime.now(), + isOnline: true, + ); + } else { + // 新设备 + _allDevices[compositeKey] = device.copyWith(lastSeen: DateTime.now()); + } _notifyDevicesChanged(); } + /// 构建 deviceId + IP 组合键用于去重 + String _buildCompositeKey(TransferDevice device) { + final ipPart = (device.ip != null && device.ip!.isNotEmpty) ? '|${device.ip}' : ''; + return '${device.id}$ipPart'; + } + void _notifyDevicesChanged() { if (_isDisposed) return; final devices = _allDevices.values.toList(); @@ -312,7 +415,8 @@ class DeviceDiscoveryNotifier extends Notifier void addSimulatedDevices(List devices) { if (_isDisposed) return; for (final device in devices) { - _allDevices[device.id] = device; + final compositeKey = _buildCompositeKey(device); + _allDevices[compositeKey] = device.copyWith(lastSeen: DateTime.now()); } _notifyDevicesChanged(); } @@ -323,6 +427,7 @@ class DeviceDiscoveryNotifier extends Notifier _bleSub?.cancel(); _nfcSub?.cancel(); _usbSub?.cancel(); + _heartbeatCleanupTimer?.cancel(); _lanService.dispose(); _bleService.dispose(); _nfcService.dispose(); diff --git a/lib/features/file_transfer/services/discovery/lan_discovery_service.dart b/lib/features/file_transfer/services/discovery/lan_discovery_service.dart index 828c2995..855073e9 100644 --- a/lib/features/file_transfer/services/discovery/lan_discovery_service.dart +++ b/lib/features/file_transfer/services/discovery/lan_discovery_service.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 局域网UDP多播发现服务 // 创建时间: 2026-05-09 -// 更新时间: 2026-05-11 +// 更新时间: 2026-06-04 // 作用: 基于LocalSend协议的局域网设备发现 — 渐进式发现策略 -// 上次更新: 修复StreamSubscription泄漏+异步安全防护 +// 上次更新: 增强设备去重( deviceId+IP ) + 心跳超时清理(30s) // ============================================================ import 'dart:async'; @@ -32,11 +32,24 @@ class LanDiscoveryService { Stream> get onDevicesChanged => _devicesController.stream; + /// 设备缓存: key = "deviceId|ip" 组合键,确保同一设备从不同IP广播时不重复 final Map _mergedDevices = {}; List get discoveredDevices => _mergedDevices.values.toList(); StreamSubscription>? _localSendSub; + + // ============================================================ + // 心跳超时清理配置 + // ============================================================ + + /// 超时阈值: 30秒未收到广播的设备视为离线 + static const Duration _offlineTimeout = Duration(seconds: 30); + + /// 清理检查间隔: 每10秒检查一次 + static const Duration _cleanupInterval = Duration(seconds: 10); + + Timer? _heartbeatCleanupTimer; Future startScan() async { if (_isScanning) return; @@ -51,9 +64,57 @@ class LanDiscoveryService { await _localSendService.startDiscovery(); + // 启动心跳超时清理定时器 + _startHeartbeatCleanup(); + _startProgressiveFallback(); } + // ============================================================ + // 心跳超时清理逻辑 + // ============================================================ + + void _startHeartbeatCleanup() { + _heartbeatCleanupTimer?.cancel(); + _heartbeatCleanupTimer = Timer.periodic(_cleanupInterval, (_) { + _cleanupStaleDevices(); + }); + Log.d('LAN Discovery: 心跳超时清理已启动 (间隔=${_cleanupInterval.inSeconds}s, 超时=${_offlineTimeout.inSeconds}s)'); + } + + void _cleanupStaleDevices() { + if (!_isScanning) return; + + final now = DateTime.now(); + final staleKeys = []; + var cleanedCount = 0; + + for (final entry in _mergedDevices.entries) { + final device = entry.value; + final elapsed = now.difference(device.lastSeen); + + if (elapsed > _offlineTimeout) { + staleKeys.add(entry.key); + cleanedCount++; + Log.d('LAN Discovery: 清理离线设备 [${device.alias}] (${device.ip}) 距上次活跃 ${elapsed.inSeconds}s'); + } + } + + // 移除超时设备 + for (final key in staleKeys) { + _mergedDevices.remove(key); + } + + if (cleanedCount > 0 && !_devicesController.isClosed) { + _devicesController.add(discoveredDevices); + Log.i('LAN Discovery: 已清理 $cleanedCount 个离线设备, 剩余 ${_mergedDevices.length} 个'); + } + } + + // ============================================================ + // 渐进式回退策略 + // ============================================================ + void _startProgressiveFallback() async { await Future.delayed(const Duration(seconds: 2)); @@ -76,18 +137,57 @@ class LanDiscoveryService { } } + // ============================================================ + // 设备合并(去重核心) + // 基于 deviceId + IP 组合键去重,同一设备更新 lastSeenAt 而非新增 + // ============================================================ + void _mergeDevices(List devices) { for (final device in devices) { - _mergedDevices[device.id] = device; + // 构建组合键: "deviceId|ip" (IP为空则仅用deviceId) + final compositeKey = _buildCompositeKey(device); + + final existing = _mergedDevices[compositeKey]; + if (existing != null) { + // 设备已存在,更新 lastSeen 时间戳和其他可变信息 + _mergedDevices[compositeKey] = existing.copyWith( + lastSeen: DateTime.now(), // 更新最后活跃时间 + isOnline: true, // 标记为在线 + ip: device.ip ?? existing.ip, + alias: device.alias.isNotEmpty ? device.alias : existing.alias, + deviceModel: device.deviceModel ?? existing.deviceModel, + ); + Log.d('LAN Discovery: 更新设备活跃时间 [${device.alias}] ($compositeKey)'); + } else { + // 新设备,添加到列表 + _mergedDevices[compositeKey] = device.copyWith(lastSeen: DateTime.now()); + Log.i('LAN Discovery: 发现新设备 [${device.alias}] (${device.ip}) ($compositeKey)'); + } } + if (!_devicesController.isClosed) { _devicesController.add(discoveredDevices); } } + /// 构建 deviceId + IP 组合键用于去重 + String _buildCompositeKey(TransferDevice device) { + final ipPart = (device.ip != null && device.ip!.isNotEmpty) ? '|${device.ip}' : ''; + return '${device.id}$ipPart'; + } + + // ============================================================ + // 生命周期管理 + // ============================================================ + Future stopScan() async { if (!_isScanning) return; _isScanning = false; + + // 停止心跳清理定时器 + _heartbeatCleanupTimer?.cancel(); + _heartbeatCleanupTimer = null; + await _localSendSub?.cancel(); _localSendSub = null; await _localSendService.stopDiscovery(); @@ -106,6 +206,7 @@ class LanDiscoveryService { await stopScan(); await stopHttpServer(); await _httpScanService.dispose(); + _heartbeatCleanupTimer?.cancel(); await _devicesController.close(); } } diff --git a/lib/features/home/presentation/cache_management_page.dart b/lib/features/home/presentation/cache_management_page.dart index dc4e8f95..ebf11eee 100644 --- a/lib/features/home/presentation/cache_management_page.dart +++ b/lib/features/home/presentation/cache_management_page.dart @@ -263,43 +263,43 @@ class _CacheManagementPageState extends ConsumerState { ), _buildCategoryRow( CupertinoIcons.chat_bubble_2, - '💬 ${t.settings.cache.chatSessions}', + t.settings.cache.chatSessions, '${stats?.chatConversationCount ?? 0} ${t.settings.cache.piecesUnit}', ext, ), _buildCategoryRow( CupertinoIcons.paperclip, - '📎 ${t.settings.cache.chatAttachments}', + t.settings.cache.chatAttachments, '${stats?.chatAttachmentCount ?? 0} ${t.settings.cache.piecesUnit} · ${stats?.chatAttachmentSizeFormatted ?? '0 B'}', ext, ), _buildCategoryRow( CupertinoIcons.trash, - '🗑️ ${t.settings.cache.chatTrash}', + t.settings.cache.chatTrash, '${stats?.chatTrashCount ?? 0} ${t.settings.cache.itemsUnit} · ${stats?.chatTrashSizeFormatted ?? '0 B'}', ext, ), _buildCategoryRow( CupertinoIcons.arrow_up_arrow_down, - '📁 ${t.settings.cache.transferRecords}', + t.settings.cache.transferRecords, '${stats?.transferRecordCount ?? 0} ${t.settings.cache.itemsUnit}', ext, ), _buildCategoryRow( CupertinoIcons.device_phone_portrait, - '🔗 ${t.settings.cache.pairedDevices}', + t.settings.cache.pairedDevices, '${stats?.pairedDeviceCount ?? 0} ${t.settings.cache.piecesUnit}', ext, ), _buildCategoryRow( CupertinoIcons.folder_badge_plus, - '📥 ${t.settings.cache.receivedFiles}', + t.settings.cache.receivedFiles, '${stats?.receivedFileCount ?? 0} ${t.settings.cache.piecesUnit} · ${stats?.receivedFileSizeFormatted ?? '0 B'}', ext, ), _buildCategoryRow( CupertinoIcons.book, - '📖 ${t.settings.cache.readLater}', + t.settings.cache.readLater, stats?.readlaterSizeFormatted ?? '0 B', ext, ), @@ -372,7 +372,7 @@ class _CacheManagementPageState extends ConsumerState { ), const SizedBox(width: 6), Text( - '🧹 ${t.settings.cache.cleanChatTrash}', + t.settings.cache.cleanChatTrash, style: AppTypography.subhead.copyWith( color: ext.accent.withValues(alpha: 0.8), fontWeight: FontWeight.w600, @@ -402,7 +402,7 @@ class _CacheManagementPageState extends ConsumerState { ), const SizedBox(width: 6), Text( - '📎 ${t.settings.cache.cleanChatThumbnails}', + t.settings.cache.cleanChatThumbnails, style: AppTypography.subhead.copyWith( color: ext.accent.withValues(alpha: 0.8), fontWeight: FontWeight.w600, @@ -460,7 +460,7 @@ class _CacheManagementPageState extends ConsumerState { ), const SizedBox(width: 6), Text( - '📁 ${t.settings.cache.cleanTransferCache}', + t.settings.cache.cleanTransferCache, style: AppTypography.subhead.copyWith( color: ext.accent.withValues(alpha: 0.8), fontWeight: FontWeight.w600, @@ -488,7 +488,7 @@ class _CacheManagementPageState extends ConsumerState { ), const SizedBox(width: 6), Text( - '💬 ${t.settings.cache.clearAllChatData}', + t.settings.cache.clearAllChatData, style: AppTypography.subhead.copyWith( color: CupertinoColors.systemRed.withValues(alpha: 0.9), fontWeight: FontWeight.w600, @@ -518,7 +518,7 @@ class _CacheManagementPageState extends ConsumerState { ), const SizedBox(width: 6), Text( - '📖 ${t.settings.cache.cleanReadlaterCache}', + t.settings.cache.cleanReadlaterCache, style: AppTypography.subhead.copyWith( color: ext.accent.withValues(alpha: 0.8), fontWeight: FontWeight.w600, @@ -548,7 +548,7 @@ class _CacheManagementPageState extends ConsumerState { ), const SizedBox(width: 6), Text( - '📖 ${t.settings.cache.clearReadlaterData}', + t.settings.cache.clearReadlaterData, style: AppTypography.subhead.copyWith( color: CupertinoColors.systemRed.withValues(alpha: 0.9), fontWeight: FontWeight.w600, diff --git a/lib/features/home/presentation/favorite_page.dart b/lib/features/home/presentation/favorite_page.dart index 3ebe3f57..1c5af130 100644 --- a/lib/features/home/presentation/favorite_page.dart +++ b/lib/features/home/presentation/favorite_page.dart @@ -1182,8 +1182,10 @@ class _FavoritePageState extends ConsumerState { try { final content = item.content.isNotEmpty ? item.content : item.title; await NfcShareService.instance.shareSentence( - sentenceId: '${item.targetType}_${item.targetId}', - content: content, + SentenceShareData( + sentenceId: '${item.targetType}_${item.targetId}', + content: content, + ), ); if (mounted) { AppToast.showSuccess('📡 ${_t.favorites.nfcShareSuccess}'); diff --git a/lib/features/home/presentation/panels/sentence_detail_actions.dart b/lib/features/home/presentation/panels/sentence_detail_actions.dart index 3c0061a5..321e0d1c 100644 --- a/lib/features/home/presentation/panels/sentence_detail_actions.dart +++ b/lib/features/home/presentation/panels/sentence_detail_actions.dart @@ -136,7 +136,7 @@ class _SentenceDetailActionsState extends ConsumerState { children: [ Expanded( child: AnimatedActionButton( - emoji: sentence.isLiked ? '❤️' : '👍', + icon: sentence.isLiked ? CupertinoIcons.heart_fill : CupertinoIcons.hand_thumbsup, label: sentence.isLiked ? '已点赞' : '点赞', isActive: sentence.isLiked, ext: ext, @@ -151,7 +151,7 @@ class _SentenceDetailActionsState extends ConsumerState { const SizedBox(width: AppSpacing.sm), Expanded( child: AnimatedActionButton( - emoji: '⭐', + icon: CupertinoIcons.star_fill, label: sentence.isFavorited ? '已收藏' : '收藏', isActive: sentence.isFavorited, ext: ext, @@ -166,7 +166,7 @@ class _SentenceDetailActionsState extends ConsumerState { const SizedBox(width: AppSpacing.sm), Expanded( child: AnimatedActionButton( - emoji: '📖', + icon: CupertinoIcons.bookmark, label: sentence.isReadLater ? '已标记' : '稍后读', isActive: sentence.isReadLater, ext: ext, @@ -182,7 +182,7 @@ class _SentenceDetailActionsState extends ConsumerState { const SizedBox(width: AppSpacing.sm), Expanded( child: AnimatedActionButton( - emoji: '↗️', + icon: CupertinoIcons.square_arrow_up, label: '分享', isActive: false, ext: ext, @@ -234,10 +234,16 @@ class _SentenceDetailActionsState extends ConsumerState { if (mounted) { final isBookmarked = result?['status'] == 'added'; HapticService.light(); - AppToast.showSuccess(isBookmarked ? '🔖 已添加书签' : '🔖 已移除书签'); + AppToast.showSuccess(isBookmarked ? '已添加书签' : '已移除书签'); } }, - child: Text('🔖 书签', style: TextStyle(color: ext.textPrimary)), + child: Row( + children: [ + Icon(CupertinoIcons.bookmark, size: 14, color: ext.textPrimary), + const SizedBox(width: 4), + Text('书签', style: TextStyle(color: ext.textPrimary)), + ], + ), ), ), const SizedBox(width: AppSpacing.sm), @@ -246,7 +252,13 @@ class _SentenceDetailActionsState extends ConsumerState { color: ext.bgSecondary, borderRadius: AppRadius.mdBorder, onPressed: _shareAsImage, - child: Text('🖼️ 图片', style: TextStyle(color: ext.textPrimary)), + child: Row( + children: [ + Icon(CupertinoIcons.photo, size: 14, color: ext.textPrimary), + const SizedBox(width: 4), + Text('壁纸', style: TextStyle(color: ext.textPrimary)), + ], + ), ), ), const SizedBox(width: AppSpacing.sm), @@ -255,7 +267,13 @@ class _SentenceDetailActionsState extends ConsumerState { color: ext.bgSecondary, borderRadius: AppRadius.mdBorder, onPressed: () => _showExternalSearchActionSheet(), - child: Text('🔍 搜索', style: TextStyle(color: ext.textPrimary)), + child: Row( + children: [ + Icon(CupertinoIcons.search, size: 14, color: ext.textPrimary), + const SizedBox(width: 4), + Text('搜索', style: TextStyle(color: ext.textPrimary)), + ], + ), ), ), const SizedBox(width: AppSpacing.sm), @@ -269,7 +287,13 @@ class _SentenceDetailActionsState extends ConsumerState { .read(sentenceDetailProvider.notifier) .toggleTtsPlayer(sentence.text); }, - child: Text('🔊 朗读', style: TextStyle(color: ext.textPrimary)), + child: Row( + children: [ + Icon(CupertinoIcons.speaker, size: 14, color: ext.textPrimary), + const SizedBox(width: 4), + Text('朗读', style: TextStyle(color: ext.textPrimary)), + ], + ), ), ), ], @@ -307,12 +331,15 @@ class _SentenceDetailActionsState extends ConsumerState { borderRadius: AppRadius.smBorder, onPressed: () { ref.read(homeProvider.notifier).blockContent(sentence.id); - AppToast.showSuccess('🚫 已屏蔽'); + AppToast.showSuccess('已屏蔽'); _closePanel(); }, - child: Text( - '🚫 屏蔽', - style: TextStyle(fontSize: 12, color: ext.accent), + child: Row( + children: [ + Icon(CupertinoIcons.xmark_circle, size: 12, color: ext.accent), + const SizedBox(width: 4), + Text('屏蔽', style: TextStyle(fontSize: 12, color: ext.accent)), + ], ), ), ), @@ -324,12 +351,15 @@ class _SentenceDetailActionsState extends ConsumerState { borderRadius: AppRadius.smBorder, onPressed: () { ref.read(homeProvider.notifier).dislikeContent(sentence.id); - AppToast.showSuccess('🙈 已标记不感兴趣'); + AppToast.showSuccess('已标记不感兴趣'); _closePanel(); }, - child: Text( - '🙈 不感兴趣', - style: TextStyle(fontSize: 12, color: ext.accent), + child: Row( + children: [ + Icon(CupertinoIcons.hand_thumbsdown, size: 12, color: ext.accent), + const SizedBox(width: 4), + Text('不感兴趣', style: TextStyle(fontSize: 12, color: ext.accent)), + ], ), ), ), diff --git a/lib/features/home/presentation/panels/sentence_detail_content.dart b/lib/features/home/presentation/panels/sentence_detail_content.dart index 528cd07c..40412b67 100644 --- a/lib/features/home/presentation/panels/sentence_detail_content.dart +++ b/lib/features/home/presentation/panels/sentence_detail_content.dart @@ -99,9 +99,10 @@ class SentenceDetailContent extends ConsumerWidget { borderRadius: BorderRadius.circular(8), ), child: Center( - child: Text( - sentence.feedIcon ?? '📖', - style: const TextStyle(fontSize: 16), + child: Icon( + CupertinoIcons.book_fill, + size: 16, + color: accentColor.withValues(alpha: 0.8), ), ), ), @@ -175,17 +176,20 @@ class SentenceDetailContent extends ConsumerWidget { children: [ if (likeCount > 0) _StatChip( - label: '👍 ${NumberFormatter.formatCount(likeCount)}', + icon: CupertinoIcons.heart_fill, + label: NumberFormatter.formatCount(likeCount), ext: ext, ), if (favCount > 0) _StatChip( - label: '⭐ ${NumberFormatter.formatCount(favCount)}', + icon: CupertinoIcons.star_fill, + label: NumberFormatter.formatCount(favCount), ext: ext, ), if (cmtCount > 0) _StatChip( - label: '💬 ${NumberFormatter.formatCount(cmtCount)}', + icon: CupertinoIcons.bubble_left_fill, + label: NumberFormatter.formatCount(cmtCount), ext: ext, ), ], @@ -425,7 +429,10 @@ class SentenceDetailContent extends ConsumerWidget { borderRadius: BorderRadius.circular(14), ), child: const Center( - child: Text('✍', style: TextStyle(fontSize: 13)), + child: Icon( + CupertinoIcons.pencil_ellipsis_rectangle, + size: 13, + ), ), ), const SizedBox(width: AppSpacing.xs), @@ -451,7 +458,7 @@ class SentenceDetailContent extends ConsumerWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('💡', style: TextStyle(fontSize: 14)), + Icon(CupertinoIcons.lightbulb_fill, size: 14, color: ext.accent), const SizedBox(width: AppSpacing.xs), Expanded( child: Text( @@ -492,8 +499,10 @@ class SentenceDetailContent extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '📝 原文内容', + Icon(CupertinoIcons.doc_text_fill, size: 12, color: ext.accent), + const SizedBox(width: 4), + Text( + '原文内容', style: AppTypography.caption2.copyWith( color: ext.accent, fontWeight: FontWeight.w600, @@ -622,8 +631,9 @@ class SentenceDetailContent extends ConsumerWidget { // ============================================================ class _StatChip extends StatelessWidget { - const _StatChip({required this.label, required this.ext}); + const _StatChip({this.icon, required this.label, required this.ext}); + final IconData? icon; final String label; final AppThemeExtension ext; @@ -638,9 +648,18 @@ class _StatChip extends StatelessWidget { color: ext.bgSecondary, borderRadius: AppRadius.pillBorder, ), - child: Text( - label, - style: AppTypography.caption2.copyWith(color: ext.textHint), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon!, size: 12, color: ext.textHint), + const SizedBox(width: 4), + ], + Text( + label, + style: AppTypography.caption2.copyWith(color: ext.textHint), + ), + ], ), ); } diff --git a/lib/features/home/presentation/providers/offline_page.dart b/lib/features/home/presentation/providers/offline_page.dart index a6498540..9df74eb8 100644 --- a/lib/features/home/presentation/providers/offline_page.dart +++ b/lib/features/home/presentation/providers/offline_page.dart @@ -473,11 +473,11 @@ class _OfflinePageState extends ConsumerState { String _preloadModeLabel(PreloadMode mode) { switch (mode) { case PreloadMode.smart: - return '🧠 ${_t.offline.smartMode}'; + return _t.offline.smartMode; case PreloadMode.wifiOnly: - return '📶 ${_t.offline.wifiOnly}'; + return _t.offline.wifiOnly; case PreloadMode.disabled: - return '🚫 ${_t.offline.disabledMode}'; + return _t.offline.disabledMode; } } @@ -850,6 +850,16 @@ class _OfflinePageState extends ConsumerState { AppToast.showWarning(_t.offline.networkUnavailable); return; } + // 预加载模式检查 + if (offlineState.config.preloadMode == PreloadMode.disabled) { + AppToast.showInfo(_t.offline.preloadModeDisabledHint); + return; + } + // WiFi Only 模式下非 WiFi 环境提示 + if (offlineState.config.preloadMode == PreloadMode.wifiOnly && !OfflineManager.isWifi) { + AppToast.showInfo(_t.offline.wifiOnlyModeHint); + return; + } if (_isPreloading) return; setState(() => _isPreloading = true); @@ -869,7 +879,8 @@ class _OfflinePageState extends ConsumerState { } else if (result.skipped > 0) { AppToast.showInfo(_t.offline.allChannelsCached); } else { - AppToast.showError(_t.offline.preloadFailed); + // 区分"条件不满足"和真正的失败 + AppToast.showInfo(_t.offline.preloadNoNewContent); } } catch (e) { if (!mounted) return; diff --git a/lib/features/home/presentation/providers/sentence_detail_sheet.dart b/lib/features/home/presentation/providers/sentence_detail_sheet.dart index 2cade40f..d8b074b8 100644 --- a/lib/features/home/presentation/providers/sentence_detail_sheet.dart +++ b/lib/features/home/presentation/providers/sentence_detail_sheet.dart @@ -717,9 +717,11 @@ class _SentenceDetailContentState Future _shareViaNfc(HomeSentence s) async { try { await NfcShareService.instance.shareSentence( - sentenceId: s.id, - content: s.text, - author: s.author, + SentenceShareData( + sentenceId: s.id, + content: s.text, + author: s.author, + ), ); if (mounted) { AppToast.showSuccess(t.home.sentenceDetail.nfcShareSuccess); diff --git a/lib/features/mine/achievement/presentation/achievement_page.dart b/lib/features/mine/achievement/presentation/achievement_page.dart index 1dbcce4c..a29a1f7a 100644 --- a/lib/features/mine/achievement/presentation/achievement_page.dart +++ b/lib/features/mine/achievement/presentation/achievement_page.dart @@ -84,7 +84,7 @@ class _AchievementPageState extends ConsumerState middle: Row( mainAxisSize: MainAxisSize.min, children: [ - const Text('🏆 成就'), + const Icon(CupertinoIcons.star_fill, size: 18), if (isOffline) ...[ const SizedBox(width: 6), const OfflineIndicator(), @@ -234,7 +234,7 @@ class _AchievementPageState extends ConsumerState const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.md)), SliverToBoxAdapter( child: _AchievementSection( - title: '✅ 已达成', + title: '已达成', achievements: state.myData?.achieved ?? [], ext: ext, onClaim: _claimReward, @@ -245,7 +245,7 @@ class _AchievementPageState extends ConsumerState const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.md)), SliverToBoxAdapter( child: _AchievementSection( - title: '🎯 进行中', + title: '进行中', achievements: state.myData?.unachieved ?? [], ext: ext, staggerBase: 300, @@ -537,7 +537,7 @@ class _AchievementPageState extends ConsumerState if (_comboCount > 1) Padding( padding: const EdgeInsets.only(top: 4), - child: Text('🔥 连续打卡 $_comboCount 次!'), + child: Text('连续打卡 $_comboCount 次!'), ), ], ), @@ -842,8 +842,8 @@ class _TitleProgress extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '🎖️ 头衔进度', + Text( + '头衔进度', style: AppTypography.title3.copyWith(color: ext.textPrimary), ), const SizedBox(height: AppSpacing.md), @@ -882,10 +882,15 @@ class _TitleProgress extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - isLocked ? '🔒' : title.icon, - style: const TextStyle(fontSize: 14), - ), + Icon( + isLocked + ? CupertinoIcons.lock_fill + : CupertinoIcons.star_fill, + size: 14, + color: isLocked + ? ext.textHint + : ext.accent, + ), const SizedBox(width: 4), Text( title.name, diff --git a/lib/features/mine/profile/presentation/profile_page.dart b/lib/features/mine/profile/presentation/profile_page.dart index 0b047da9..c1b68958 100644 --- a/lib/features/mine/profile/presentation/profile_page.dart +++ b/lib/features/mine/profile/presentation/profile_page.dart @@ -18,7 +18,6 @@ import '../../../../core/theme/app_typography.dart'; import '../../../../core/theme/app_radius.dart'; import '../../../../core/router/app_routes.dart'; import '../../../../core/storage/kv_storage.dart'; -import '../../../../core/services/feature/feature_flag_service.dart'; import '../../../../core/providers/split_view_provider.dart'; import '../../../../core/layout/adaptive_split_view.dart'; import '../../../../l10n/app_locale.dart'; @@ -149,9 +148,7 @@ class _ProfilePageState extends ConsumerState { CupertinoActionSheetAction( onPressed: () { Navigator.pop(menuCtx); - AppToast.showInfo( - FeatureFlag.qrScan.unsupportedMessage, - ); + context.appPush(AppRoutes.qrcodeScanner); }, child: Row( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/features/mine/settings/presentation/account/account_settings_page.dart b/lib/features/mine/settings/presentation/account/account_settings_page.dart index 20639aa0..c91e4055 100644 --- a/lib/features/mine/settings/presentation/account/account_settings_page.dart +++ b/lib/features/mine/settings/presentation/account/account_settings_page.dart @@ -21,7 +21,6 @@ import 'package:xianyan/shared/widgets/containers/glass_container.dart'; import 'package:xianyan/features/auth/providers/auth_provider.dart'; import 'package:xianyan/shared/widgets/adaptive/adaptive_back_button.dart'; import 'package:xianyan/features/mine/user_center/presentation/widgets/edit_field_bottom_sheet.dart'; -import 'account_avatar_section.dart'; import 'account_bind_email_dialog.dart'; import 'account_bind_mobile_dialog.dart'; import 'account_export_info_sheet.dart'; @@ -68,15 +67,6 @@ class AccountSettingsPage extends ConsumerWidget { vertical: AppSpacing.sm, ), children: [ - AccountAvatarSection(user: user) - .animate(onPlay: (c) => c.forward()) - .fadeIn(duration: 350.ms) - .slideY( - begin: 0.05, - end: 0, - duration: 350.ms, - curve: Curves.easeOutCubic, - ), const SizedBox(height: AppSpacing.md), AccountSecurityScoreCard(user: user) .animate(onPlay: (c) => c.forward()) diff --git a/lib/features/mine/settings/presentation/data_management_page.dart b/lib/features/mine/settings/presentation/data_management_page.dart index 81016897..7ce76d12 100644 --- a/lib/features/mine/settings/presentation/data_management_page.dart +++ b/lib/features/mine/settings/presentation/data_management_page.dart @@ -13,6 +13,7 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:syncfusion_flutter_charts/charts.dart'; import '../../../../core/services/data/backup_service.dart'; +import '../../../../core/services/data/image_cache_metadata_service.dart'; import '../../../../core/storage/database/app_database.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../core/theme/app_spacing.dart'; @@ -56,7 +57,6 @@ class _DataManagementPageState extends ConsumerState @override Future loadAllStats() async { final db = AppDatabase.instance; - final settings = ref.read(generalSettingsProvider); final results = await Future.wait([ db.getFavoriteCount(), @@ -68,6 +68,13 @@ class _DataManagementPageState extends ConsumerState db.getOfflineActionCount(), ]); + // 读取图片缓存元数据服务的实际缓存大小 + int imageCacheTotalSize = 0; + try { + await ImageCacheMetadataService.init(); + imageCacheTotalSize = await ImageCacheMetadataService.getTotalSize(); + } catch (_) {} + autoBackupEnabled = BackupService.autoBackupEnabled; backups = await BackupService.getBackupList(); totalBackupSize = await BackupService.getTotalBackupSize(); @@ -81,12 +88,25 @@ class _DataManagementPageState extends ConsumerState feedCacheCount = results[4]; hanziCacheCount = results[5]; offlineCount = results[6]; - cacheSizeText = settings.cacheSizeText; + cacheSizeText = _formatBytes(imageCacheTotalSize); isLoading = false; }); } } + /// 格式化字节数为可读字符串 + static String _formatBytes(int bytes) { + if (bytes <= 0) return '0 B'; + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } + @override Widget build(BuildContext context) { final ext = AppTheme.ext(context); @@ -97,7 +117,7 @@ class _DataManagementPageState extends ConsumerState navigationBar: CupertinoNavigationBar( leading: const AdaptiveBackButton(), middle: Text( - '💾 ${t.dataManagement.title}', + t.dataManagement.title, style: AppTypography.title3.copyWith(color: ext.textPrimary), ), backgroundColor: ext.bgElevated.withValues(alpha: 0.85), diff --git a/lib/features/mine/signin/presentation/signin_page.dart b/lib/features/mine/signin/presentation/signin_page.dart index a960b86d..364467e1 100644 --- a/lib/features/mine/signin/presentation/signin_page.dart +++ b/lib/features/mine/signin/presentation/signin_page.dart @@ -36,6 +36,7 @@ class SigninPage extends ConsumerStatefulWidget { class _SigninPageState extends ConsumerState { late ConfettiController _confettiController; + final Set _expandedItems = {}; @override void initState() { @@ -149,6 +150,16 @@ class _SigninPageState extends ConsumerState { delay: 300.ms, ), const SizedBox(height: AppSpacing.lg), + _buildSigninHistory(ext, signinState) + .animate() + .fadeIn(duration: 400.ms, delay: 450.ms) + .slideY( + begin: 0.1, + end: 0, + duration: 400.ms, + delay: 450.ms, + ), + const SizedBox(height: AppSpacing.lg), CupertinoButton( onPressed: () => context.appPush(AppRoutes.achievement), @@ -609,6 +620,308 @@ class _SigninPageState extends ConsumerState { ); } + /// 签到历史记录列表 + Widget _buildSigninHistory(AppThemeExtension ext, SigninState signinState) { + final signedDates = signinState.signedDates.toList(); + // 按日期降序排序(最新的在前) + signedDates.sort((a, b) => b.compareTo(a)); + + return GlassContainer( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + CupertinoIcons.clock_fill, + size: 18, + color: ext.textPrimary, + ), + const SizedBox(width: 6), + Text( + '签到历史记录', + style: AppTypography.headline.copyWith(color: ext.textPrimary), + ), + const SizedBox(width: AppSpacing.sm), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 2, + ), + decoration: BoxDecoration( + color: ext.accent.withValues(alpha: 0.15), + borderRadius: AppRadius.pillBorder, + ), + child: Text( + '${signedDates.length}次', + style: AppTypography.caption2.copyWith( + color: ext.accent, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + if (signedDates.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xl), + child: Column( + children: [ + Icon( + CupertinoIcons.doc_text, + size: 40, + color: ext.textHint, + ), + const SizedBox(height: AppSpacing.sm), + Text( + '暂无签到记录', + style: AppTypography.subhead.copyWith( + color: ext.textHint, + ), + ), + ], + ), + ), + ) + else + Container( + constraints: const BoxConstraints(maxHeight: 300), + decoration: BoxDecoration( + color: ext.bgSecondary.withValues(alpha: 0.3), + borderRadius: AppRadius.mdBorder, + ), + child: ListView.builder( + shrinkWrap: true, + physics: const AlwaysScrollableScrollPhysics(), + itemCount: signedDates.length, + itemBuilder: (context, index) { + final dateStr = signedDates[index]; + final isExpanded = _expandedItems.contains(index); + return _buildHistoryItem( + index: index, + dateStr: dateStr, + isExpanded: isExpanded, + ext: ext, + ); + }, + ), + ), + ], + ), + ); + } + + /// 单条签到历史记录项 + Widget _buildHistoryItem({ + required int index, + required String dateStr, + required bool isExpanded, + required AppThemeExtension ext, + }) { + // 解析日期 + final parts = dateStr.split('-'); + final year = parts.length > 0 ? parts[0] : ''; + final month = parts.length > 1 ? parts[1] : ''; + final day = parts.length > 2 ? parts[2] : ''; + + // 格式化显示日期 + final displayDate = '$year年$month月$day日'; + + // 判断是否是今天的签到 + final now = DateTime.now(); + final todayStr = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + final isToday = dateStr == todayStr; + + return Column( + children: [ + GestureDetector( + onTap: () { + setState(() { + if (isExpanded) { + _expandedItems.remove(index); + } else { + _expandedItems.add(index); + } + }); + }, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: ext.overlaySubtle.withValues(alpha: 0.3), + width: 0.5, + ), + ), + ), + child: Row( + children: [ + // 序号图标 + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: isToday + ? ext.accent.withValues(alpha: 0.15) + : ext.bgSecondary, + shape: BoxShape.circle, + ), + child: Center( + child: Icon( + CupertinoIcons.checkmark_circle_fill, + size: 14, + color: isToday ? ext.accent : ext.textHint, + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + // 日期文本 + Expanded( + child: Text( + displayDate, + style: AppTypography.subhead.copyWith( + color: isToday ? ext.accent : ext.textPrimary, + fontWeight: isToday ? FontWeight.w600 : FontWeight.normal, + ), + ), + ), + // 今日标签 + if (isToday) + Container( + margin: const EdgeInsets.only(right: AppSpacing.sm), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 2, + ), + decoration: BoxDecoration( + color: ext.accent.withValues(alpha: 0.15), + borderRadius: AppRadius.smBorder, + ), + child: Text( + '今日', + style: AppTypography.caption2.copyWith( + color: ext.accent, + fontWeight: FontWeight.w600, + ), + ), + ), + // 展开/收起图标 + Icon( + isExpanded + ? CupertinoIcons.chevron_up + : CupertinoIcons.chevron_down, + size: 14, + color: ext.textHint, + ), + ], + ), + ), + ), + // 展开详情 + if (isExpanded) + Container( + margin: const EdgeInsets.only( + left: AppSpacing.lg, + right: AppSpacing.md, + bottom: AppSpacing.sm, + ), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: ext.bgCard, + borderRadius: AppRadius.mdBorder, + border: Border.all( + color: ext.overlaySubtle.withValues(alpha: 0.5), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + CupertinoIcons.calendar, + size: 14, + color: ext.textSecondary, + ), + const SizedBox(width: 6), + Text( + '签到日期', + style: AppTypography.caption1.copyWith( + color: ext.textSecondary, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xs), + Text( + dateStr, + style: AppTypography.subhead.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Icon( + CupertinoIcons.checkmark_shield_fill, + size: 14, + color: CupertinoColors.systemGreen, + ), + const SizedBox(width: 6), + Text( + '状态', + style: AppTypography.caption1.copyWith( + color: ext.textSecondary, + ), + ), + const Spacer(), + Text( + '已签到 ✓', + style: AppTypography.caption1.copyWith( + color: CupertinoColors.systemGreen, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xs), + Row( + children: [ + Icon( + CupertinoIcons.time, + size: 14, + color: ext.textHint, + ), + const SizedBox(width: 6), + Text( + '备注', + style: AppTypography.caption1.copyWith( + color: ext.textHint, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + '完成每日签到任务', + style: AppTypography.caption2.copyWith(color: ext.textHint), + ), + ], + ), + ), + ], + ); + } + void _showMakeupDialog(String dateStr, int day) { final ext = AppTheme.ext(context); final userScore = ref.read(authProvider).user?.score ?? 0; diff --git a/lib/features/note/presentation/note_list_page.dart b/lib/features/note/presentation/note_list_page.dart index f87f9158..37167b93 100644 --- a/lib/features/note/presentation/note_list_page.dart +++ b/lib/features/note/presentation/note_list_page.dart @@ -48,10 +48,10 @@ class _NoteListPageState extends ConsumerState { static const _kLayoutKey = 'note_layout'; static const _filterOptions = >[ - MapEntry('', '📋 全部'), - MapEntry('note', '📝 笔记'), - MapEntry('excerpt', '✂️ 摘抄'), - MapEntry('checklist', '✅ 清单'), + MapEntry('', '全部'), + MapEntry('note', '笔记'), + MapEntry('excerpt', '摘抄'), + MapEntry('checklist', '清单'), ]; @override @@ -127,7 +127,7 @@ class _NoteListPageState extends ConsumerState { return CupertinoPageScaffold( navigationBar: CupertinoNavigationBar( leading: const AdaptiveBackButton(), - middle: const Text('📝 我的笔记'), + middle: const Text('我的笔记'), trailing: authState.isLoggedIn ? Row( mainAxisSize: MainAxisSize.min, @@ -198,7 +198,7 @@ class _NoteListPageState extends ConsumerState { Navigator.pop(ctx); context.appPush(AppRoutes.noteEdit); }, - child: const Text('📝 新建笔记'), + child: const Text('新建笔记'), ), CupertinoActionSheetAction( isDestructiveAction: true, @@ -214,7 +214,7 @@ class _NoteListPageState extends ConsumerState { AppToast.showSuccess('已清空全部笔记'); } }, - child: const Text('🗑️ 清空全部'), + child: const Text('清空全部'), ), ], cancelButton: CupertinoActionSheetAction( @@ -253,7 +253,7 @@ class _NoteListPageState extends ConsumerState { ), const SizedBox(height: 16), Text( - '🎨 切换布局风格', + '切换布局风格', style: AppTypography.headline.copyWith(color: ext.textPrimary), ), const SizedBox(height: 16), @@ -463,10 +463,7 @@ class _NoteListPageState extends ConsumerState { if (note.isPublicNote) Padding( padding: const EdgeInsets.only(left: 4), - child: Text( - '🌐', - style: TextStyle(fontSize: 12, color: ext.textHint), - ), + child: Icon(CupertinoIcons.globe, size: 12, color: ext.textHint), ), ], ), @@ -542,10 +539,7 @@ class _NoteListPageState extends ConsumerState { _buildTypeBadge(ext, note, compact: true), const Spacer(), if (note.isPublicNote) - Text( - '🌐', - style: TextStyle(fontSize: 10, color: ext.textHint), - ), + Icon(CupertinoIcons.globe, size: 10, color: ext.textHint), ], ), const SizedBox(height: 6), @@ -738,10 +732,10 @@ class _NoteListPageState extends ConsumerState { NoteModel note, { bool compact = false, }) { - final (emoji, label, color) = switch (note.noteType) { - 'excerpt' => ('✂️', '摘抄', const Color(0xFFFF9500)), - 'checklist' => ('✅', '清单', const Color(0xFF34C759)), - _ => ('📝', '笔记', ext.accent), + final (iconData, label, color) = switch (note.noteType) { + 'excerpt' => (CupertinoIcons.scissors, '摘抄', const Color(0xFFFF9500)), + 'checklist' => (CupertinoIcons.checkmark_circle_fill, '清单', const Color(0xFF34C759)), + _ => (CupertinoIcons.doc_text_fill, '笔记', ext.accent), }; return Container( @@ -753,13 +747,15 @@ class _NoteListPageState extends ConsumerState { color: color.withValues(alpha: 0.12), borderRadius: AppRadius.smBorder, ), - child: Text( - compact ? emoji : '$emoji $label', - style: AppTypography.caption2.copyWith( - color: color, - fontWeight: FontWeight.w600, - fontSize: compact ? 11 : null, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(iconData, size: compact ? 11 : 13, color: color), + if (!compact) ...[ + const SizedBox(width: 4), + Text(label, style: AppTypography.caption2.copyWith(color: color, fontWeight: FontWeight.w600, fontSize: compact ? 11 : null)), + ], + ], ), ); } @@ -778,7 +774,7 @@ class _NoteListPageState extends ConsumerState { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text('📖', style: TextStyle(fontSize: compact ? 10 : 12)), + Icon(CupertinoIcons.book_fill, size: compact ? 10 : 12), SizedBox(width: compact ? 3 : 4), Flexible( child: Text( @@ -955,9 +951,9 @@ class _NoteListPageState extends ConsumerState { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - isNearLimit ? '⚠️' : '📊', - style: const TextStyle(fontSize: 12), + Icon( + isNearLimit ? CupertinoIcons.exclamationmark_triangle : CupertinoIcons.chart_bar, + size: 12, ), const SizedBox(width: 6), Text( @@ -1001,7 +997,7 @@ class _NoteListPageState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text('🔐', style: TextStyle(fontSize: 48)), + Icon(CupertinoIcons.lock, size: 48), const SizedBox(height: AppSpacing.md), Text( '请先登录', @@ -1022,7 +1018,7 @@ class _NoteListPageState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text('📝', style: TextStyle(fontSize: 48)), + Icon(CupertinoIcons.doc_text, size: 48), const SizedBox(height: AppSpacing.md), Text( '暂无笔记', @@ -1095,7 +1091,7 @@ class _NoteListPageState extends ConsumerState { color: ext.bgPrimary, child: Row( children: [ - const Text('📅', style: TextStyle(fontSize: 14)), + Icon(CupertinoIcons.calendar, size: 14), const SizedBox(width: AppSpacing.xs), Text( dateLabel, @@ -1189,7 +1185,7 @@ class _NoteListPageState extends ConsumerState { Navigator.pop(ctx); context.appPush('${AppRoutes.noteEdit}?id=${note.id}'); }, - child: const Text('✏️ 编辑'), + child: const Text('编辑'), ), CupertinoActionSheetAction( isDestructiveAction: true, @@ -1197,7 +1193,7 @@ class _NoteListPageState extends ConsumerState { Navigator.pop(ctx); _confirmDelete(note); }, - child: const Text('🗑️ 删除'), + child: const Text('删除'), ), ], cancelButton: CupertinoActionSheetAction( diff --git a/lib/features/progress/presentation/progress_beautify_page.dart b/lib/features/progress/presentation/progress_beautify_page.dart new file mode 100644 index 00000000..1e81417e --- /dev/null +++ b/lib/features/progress/presentation/progress_beautify_page.dart @@ -0,0 +1,674 @@ +/// ============================================================ +/// 闲言APP — 进度美化页面 +/// 创建时间: 2026-06-04 +/// 更新时间: 2026-06-04 +/// 作用: 展示进度的美化视图,支持自定义背景主题、保存为图片分享 +/// 上次更新: 初始创建 +/// ============================================================ + +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart' as share_plus; + +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 '../../../shared/widgets/adaptive/adaptive_back_button.dart'; +import '../../../shared/widgets/containers/glass_container.dart'; + +// ---- 美化主题定义 ---- + +/// 美化背景主题 +enum BeautifyTheme { + ocean('海洋蓝', [ + Color(0xFF007AFF), + Color(0xFF5AC8FA), + Color(0xFFE8F4FD), + ]), + sunset('日落橙', [ + Color(0xFFFF9500), + Color(0xFFFF6B35), + Color(0xFFFFF0E0), + ]), + forest('森林绿', [ + Color(0xFF34C759), + Color(0xFF30D158), + Color(0xFFF0FFF4), + ]), + sakura('樱花粉', [ + Color(0xFFFF2D55), + Color(0xFFFF6B81), + Color(0xFFFFF0F3), + ]), + galaxy('星空紫', [ + Color(0xFFAF52DE), + Color(0xFF5856D6), + Color(0xFFF0E6FF), + ]), + midnight('暗夜黑', [ + Color(0xFFFFFFFF), + Color(0xFF8E8E93), + Color(0xFF1C1C1E), + ]); + + const BeautifyTheme(this.name, this.colors); + final String name; + final List colors; + + /// 主渐变色 + List get gradientColors => [colors[0], colors[1]]; + /// 背景色 + Color get bgColor => colors[2]; +} + +// ---- 页面主体 ---- + +class ProgressBeautifyPage extends StatefulWidget { + const ProgressBeautifyPage({super.key}); + + /// 从路由参数构建 + static Map extractArgs(Object? extra) { + if (extra is Map) return extra; + if (extra is Map) { + return { + 'title': extra['title'] as String? ?? '', + 'startDate': extra['startDate'] as DateTime?, + 'description': extra['description'] as String? ?? '', + 'progress': (extra['progress'] as num?)?.toDouble() ?? 0.0, + 'elapsedDays': (extra['elapsedDays'] as num?)?.toInt() ?? 0, + 'totalDays': (extra['totalDays'] as num?)?.toInt() ?? 0, + 'emoji': extra['emoji'] as String? ?? '📊', + }; + } + return const { + 'title': '', + 'startDate': null, + 'description': '', + 'progress': 0.0, + 'elapsedDays': 0, + 'totalDays': 0, + 'emoji': '📊', + }; + } + + @override + State createState() => _ProgressBeautifyPageState(); +} + +class _ProgressBeautifyPageState extends State { + late Map _args; + BeautifyTheme _currentTheme = BeautifyTheme.ocean; + final GlobalKey _repaintKey = GlobalKey(); + bool _isSaving = false; + + // ---- 数据获取 ---- + + String get title => _args['title'] as String; + DateTime? get startDate => _args['startDate'] as DateTime?; + String get description => _args['description'] as String; + double get progress => (_args['progress'] as double).clamp(0.0, 1.0); + int get elapsedDays => _args['elapsedDays'] as int; + int get totalDays => _args['totalDays'] as int; + String get emoji => _args['emoji'] as String; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final route = ModalRoute.of(context); + final extra = route?.settings.arguments; + setState(() { + _args = ProgressBeautifyPage.extractArgs(extra); + }); + }); + } + + @override + Widget build(BuildContext context) { + final ext = AppTheme.ext(context); + + return CupertinoPageScaffold( + backgroundColor: ext.bgPrimary, + navigationBar: CupertinoNavigationBar( + leading: const AdaptiveBackButton(), + middle: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(CupertinoIcons.sparkles, size: 18, color: ext.accent), + const SizedBox(width: 6), + Text( + '进度美化', + style: AppTypography.title3.copyWith(color: ext.textPrimary), + ), + ], + ), + backgroundColor: ext.bgPrimary.withValues(alpha: 0.85), + border: null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () => _showThemePicker(ext), + child: Icon(CupertinoIcons.paintbrush, color: ext.iconPrimary, size: 22), + ), + const SizedBox(width: 4), + CupertinoButton( + padding: EdgeInsets.zero, + onPressed: _isSaving ? null : () => _saveAndShare(), + child: Icon( + CupertinoIcons.share_up, + color: _isSaving ? ext.textHint : ext.iconPrimary, + size: 22, + ), + ), + ], + ), + ), + child: SafeArea( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + children: [ + // 美化预览卡片(可截图区域) + RepaintBoundary( + key: _repaintKey, + child: _buildBeautifyCard(ext), + ).animate().fadeIn(duration: 500.ms).slideY(begin: 0.05, end: 0), + const SizedBox(height: AppSpacing.lg), + // 主题选择器 + _buildThemeSelector(ext).animate().fadeIn(delay: 300.ms, duration: 400.ms), + const SizedBox(height: AppSpacing.lg), + // 操作按钮区 + _buildActionButtons(ext).animate().fadeIn(delay: 450.ms, duration: 400.ms), + const SizedBox(height: 80), + ], + ), + ), + ), + ), + ); + } + + // ---- 美化卡片 ---- + + Widget _buildBeautifyCard(AppThemeExtension ext) { + return Container( + width: double.infinity, + constraints: const BoxConstraints(minHeight: 420), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: _currentTheme.gradientColors, + ), + borderRadius: BorderRadius.circular(AppRadius.xl), + boxShadow: [ + BoxShadow( + color: _currentTheme.colors[0].withValues(alpha: 0.25), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + padding: const EdgeInsets.all(AppSpacing.xl), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Emoji 图标 + Text(emoji, style: const TextStyle(fontSize: 48)) + .animate(onPlay: (c) => c.repeat()) + .scale( + begin: const Offset(1, 1), + end: const Offset(1.08, 1.08), + duration: 2000.ms, + curve: Curves.easeInOut, + ), + const SizedBox(height: AppSpacing.md), + // 标题 + Text( + title.isNotEmpty ? title : '我的进度', + style: AppTypography.title1.copyWith( + color: CupertinoColors.white, + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, + ).animate().fadeIn(delay: 150.ms, duration: 400.ms), + if (description.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + description, + style: AppTypography.subhead.copyWith( + color: CupertinoColors.white.withValues(alpha: 0.85), + ), + textAlign: TextAlign.center, + ).animate().fadeIn(delay: 250.ms, duration: 400.ms), + ], + const SizedBox(height: AppSpacing.xl), + // 大字天数展示 + _buildDayDisplay() + .animate(onPlay: (c) => c.repeat(reverse: true)) + .then() + .slideY(begin: -0.03, end: 0.03, duration: 2500.ms, curve: Curves.easeInOut), + const SizedBox(height: AppSpacing.lg), + // 圆形进度环 + _buildProgressRing() + .animate(onPlay: (c) => c.loop(count: 1)) + .scale(begin: const Offset(0.5, 0.5), end: const Offset(1, 1), duration: 800.ms, curve: Curves.elasticOut), + const SizedBox(height: AppSpacing.sm), + // 起始日期信息 + if (startDate != null) + Text( + '起始日期:${startDate!.year}-${startDate!.month.toString().padLeft(2, '0')}-${startDate!.day.toString().padLeft(2, '0')}', + style: AppTypography.caption1.copyWith( + color: CupertinoColors.white.withValues(alpha: 0.7), + ), + ).animate().fadeIn(delay: 600.ms, duration: 400.ms), + ], + ), + ); + } + + // ---- 天数显示组件 ---- + + Widget _buildDayDisplay() { + final days = elapsedDays > 0 ? elapsedDays : 1; + final hours = _getElapsedHours(); + final minutes = _getElapsedMinutes(); + + return Column( + children: [ + Text( + 'Day $days', + style: const TextStyle( + fontSize: 56, + fontWeight: FontWeight.w900, + color: CupertinoColors.white, + letterSpacing: -1, + height: 1.1, + ), + ), + const SizedBox(height: AppSpacing.xs), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildTimeUnit('$totalDays', '总天', CupertinoColors.white.withValues(alpha: 0.9)), + Container( + margin: const EdgeInsets.symmetric(horizontal: 12), + width: 1, + height: 20, + color: CupertinoColors.white.withValues(alpha: 0.3), + ), + _buildTimeUnit('${hours}h', '时', CupertinoColors.white.withValues(alpha: 0.75)), + Container( + margin: const EdgeInsets.symmetric(horizontal: 12), + width: 1, + height: 20, + color: CupertinoColors.white.withValues(alpha: 0.3), + ), + _buildTimeUnit('${minutes}m', '分', CupertinoColors.white.withValues(alpha: 0.75)), + ], + ), + ], + ); + } + + Widget _buildTimeUnit(String value, String label, Color color) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: color)), + Text(label, style: TextStyle(fontSize: 11, color: color.withValues(alpha: 0.7))), + ], + ); + } + + int _getElapsedHours() { + if (startDate == null) return 0; + final diff = DateTime.now().difference(startDate!); + return diff.inHours % 24; + } + + int _getElapsedMinutes() { + if (startDate == null) return 0; + final diff = DateTime.now().difference(startDate!); + return diff.inMinutes % 60; + } + + // ---- 圆形进度环 ---- + + Widget _buildProgressRing() { + final pct = progress.clamp(0.0, 1.0); + + return SizedBox( + width: 140, + height: 140, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 140, + height: 140, + child: CircularProgressIndicator( + value: 1.0, + strokeWidth: 10, + backgroundColor: CupertinoColors.white.withValues(alpha: 0.15), + valueColor: AlwaysStoppedAnimation(CupertinoColors.white.withValues(alpha: 0.15)), + strokeCap: StrokeCap.round, + ), + ), + TweenAnimationBuilder( + tween: Tween(begin: 0, end: pct), + duration: const Duration(milliseconds: 1200), + curve: Curves.easeOutCubic, + builder: (context, value, child) { + return SizedBox( + width: 140, + height: 140, + child: CircularProgressIndicator( + value: value, + strokeWidth: 10, + backgroundColor: Colors.transparent, + valueColor: const AlwaysStoppedAnimation(CupertinoColors.white), + strokeCap: StrokeCap.round, + ), + ); + }, + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${(pct * 100).toInt()}%', + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: CupertinoColors.white, + ), + ), + Text( + '完成度', + style: TextStyle( + fontSize: 12, + color: CupertinoColors.white.withValues(alpha: 0.7), + ), + ), + ], + ), + ], + ), + ); + } + + // ---- 主题选择器 ---- + + Widget _buildThemeSelector(AppThemeExtension ext) { + return GlassContainer( + depth: GlassDepth.base, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(CupertinoIcons.paintbrush, size: 18, color: ext.accent), + const SizedBox(width: AppSpacing.sm), + Text('切换主题', style: AppTypography.headline.copyWith(color: ext.textPrimary)), + ], + ), + const SizedBox(height: AppSpacing.md), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: BeautifyTheme.values.map((theme) { + final isSelected = theme == _currentTheme; + return GestureDetector( + onTap: () => setState(() => _currentTheme = theme), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + gradient: LinearGradient(colors: [theme.colors[0], theme.colors[1]]), + borderRadius: AppRadius.fullBorder, + border: isSelected + ? Border.all(color: ext.accent, width: 2.5) + : null, + boxShadow: isSelected + ? [ + BoxShadow( + color: ext.accent.withValues(alpha: 0.3), + blurRadius: 8, + ) + ] + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(theme.name, style: AppTypography.caption1.copyWith( + color: CupertinoColors.white, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + )), + if (isSelected) ...[ + const SizedBox(width: 4), + const Icon(CupertinoIcons.checkmark_circle_fill, size: 14, color: CupertinoColors.white), + ], + ], + ), + ), + ); + }).toList(), + ), + ], + ), + ); + } + + // ---- 操作按钮 ---- + + Widget _buildActionButtons(AppThemeExtension ext) { + return Row( + children: [ + Expanded( + child: CupertinoButton( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(AppRadius.md), + padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + onPressed: _isSaving ? null : () => _saveToGallery(), + child: _isSaving + ? CupertinoActivityIndicator(color: ext.textSecondary) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(CupertinoIcons.square_arrow_down, size: 18, color: ext.textPrimary), + const SizedBox(width: 6), + Text('保存图片', style: AppTypography.subhead.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + )), + ], + ), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: CupertinoButton.filled( + borderRadius: BorderRadius.circular(AppRadius.md), + padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + onPressed: _isSaving ? null : () => _saveAndShare(), + child: _isSaving + ? CupertinoActivityIndicator(color: ext.textOnAccent) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(CupertinoIcons.share_up, size: 18, color: CupertinoColors.white), + const SizedBox(width: 6), + Text('分享', style: AppTypography.body.copyWith(color: CupertinoColors.white, fontWeight: FontWeight.w600)), + ], + ), + ), + ), + ], + ); + } + + // ---- 主题选择弹窗 ---- + + void _showThemePicker(AppThemeExtension ext) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => Container( + decoration: BoxDecoration( + color: ext.bgCard, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + margin: const EdgeInsets.symmetric(vertical: 12), + width: 36, + height: 4, + decoration: BoxDecoration( + color: ext.textHint.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('选择主题', style: AppTypography.title3.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w700, + )), + CupertinoButton( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + onPressed: () => Navigator.pop(ctx), + child: Text('完成', style: AppTypography.body.copyWith(color: ext.accent)), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.sm), + SizedBox( + height: 120, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + itemCount: BeautifyTheme.values.length, + separatorBuilder: (_, __) => const SizedBox(width: AppSpacing.sm), + itemBuilder: (_, index) { + final theme = BeautifyTheme.values[index]; + final isSelected = theme == _currentTheme; + return GestureDetector( + onTap: () { + setState(() => _currentTheme = theme); + Navigator.pop(ctx); + }, + child: Container( + width: 100, + decoration: BoxDecoration( + gradient: LinearGradient(colors: theme.gradientColors), + borderRadius: BorderRadius.circular(AppRadius.lg), + border: isSelected + ? Border.all(color: ext.accent, width: 2.5) + : null, + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSelected) + const Icon(CupertinoIcons.checkmark_circle_fill, + size: 20, color: CupertinoColors.white), + Text(theme.name, style: AppTypography.caption1.copyWith( + color: CupertinoColors.white, + fontWeight: FontWeight.w600, + )), + ], + ), + ), + ), + ); + }, + ), + ), + const SizedBox(height: AppSpacing.lg), + ], + ), + ), + ), + ); + } + + // ---- 截图与保存 ---- + + Future _captureWidget() async { + try { + final boundary = _repaintKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; + if (boundary == null) return null; + final image = await boundary.toImage(pixelRatio: 3.0); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + return byteData?.buffer.asUint8List(); + } catch (e) { + debugPrint('截图失败: $e'); + return null; + } + } + + Future _saveToGallery() async { + if (_isSaving) return; + setState(() => _isSaving = true); + try { + final bytes = await _captureWidget(); + if (bytes == null || !mounted) return; + final dir = await getTemporaryDirectory(); + final file = File('${dir.path}/progress_${DateTime.now().millisecondsSinceEpoch}.png'); + await file.writeAsBytes(bytes); + // 使用系统分享 + await share_plus.SharePlus.instance.share( + share_plus.ShareParams( + files: [share_plus.XFile(file.path)], + text: '$emoji $title — 进度 ${(progress * 100).toInt()}%', + ), + ); + } catch (e) { + if (mounted) { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('保存失败'), + content: Text('$e'), + actions: [ + CupertinoDialogAction( + child: const Text('确定'), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + ); + } + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + Future _saveAndShare() async { + await _saveToGallery(); + } +} diff --git a/lib/features/progress/presentation/progress_page.dart b/lib/features/progress/presentation/progress_page.dart index b32b99f8..32512e5e 100644 --- a/lib/features/progress/presentation/progress_page.dart +++ b/lib/features/progress/presentation/progress_page.dart @@ -19,6 +19,7 @@ import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_typography.dart'; import '../../../shared/widgets/adaptive/adaptive_back_button.dart'; import '../../../shared/widgets/containers/glass_container.dart'; +import 'package:xianyan/core/router/app_nav_extension.dart'; import '../models/progress_models.dart'; import '../providers/progress_provider.dart'; import 'progress_bubble.dart'; @@ -113,6 +114,15 @@ class _ProgressPageState extends ConsumerState size: 22, ), ), + CupertinoButton( + padding: EdgeInsets.zero, + onPressed: _openBeautifyPage, + child: Icon( + CupertinoIcons.sparkles, + color: ext.accent, + size: 22, + ), + ), CupertinoButton( padding: EdgeInsets.zero, onPressed: () => ProgressSettingsSheet.show(context), @@ -351,6 +361,45 @@ class _ProgressPageState extends ConsumerState ProgressAddSheet.show(context, prefillName: text, onAdded: _scrollToBottom); } + /// 打开进度美化页面 + void _openBeautifyPage() { + final state = ref.read(progressProvider); + final allItems = [...state.systemItems, ...state.userItems]; + if (allItems.isEmpty) { + // 无数据时使用默认参数 + context.appPush('/progress/beautify', extra: { + 'title': '我的进度', + 'startDate': DateTime.now(), + 'description': '', + 'progress': 0.0, + 'elapsedDays': 0, + 'totalDays': 0, + 'emoji': '📊', + }); + return; + } + // 使用第一个有效进度项的数据 + final item = allItems.first; + final remaining = item.remaining; + final targetDate = item.targetDate; + int elapsedDays = 0; + int totalDays = 0; + if (targetDate != null && remaining != null) { + totalDays = targetDate.difference(item.createdAt ?? DateTime.now()).inDays; + elapsedDays = totalDays - remaining.inDays; + if (elapsedDays < 0) elapsedDays = 0; + } + context.appPush('/progress/beautify', extra: { + 'title': item.title, + 'startDate': item.createdAt, + 'description': item.subtitle, + 'progress': item.progressPct ?? item.ringPct ?? 0.5, + 'elapsedDays': elapsedDays, + 'totalDays': totalDays > 0 ? totalDays : (item.remaining?.inDays ?? 30), + 'emoji': item.emoji, + }); + } + void _showSortPicker() { final state = ref.read(progressProvider); final ext = AppTheme.ext(context); diff --git a/lib/features/rank/presentation/rank_page.dart b/lib/features/rank/presentation/rank_page.dart index 6d222860..5fdcecee 100644 --- a/lib/features/rank/presentation/rank_page.dart +++ b/lib/features/rank/presentation/rank_page.dart @@ -5,9 +5,11 @@ import 'package:confetti/confetti.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_typography.dart'; +import 'package:xianyan/features/auth/providers/auth_provider.dart'; import '../models/rank_models.dart'; import '../providers/rank_provider.dart'; import '../services/rank_service.dart'; @@ -122,6 +124,11 @@ class _RankPageState extends ConsumerState { final rankState = ref.watch(rankProvider); final ext = AppTheme.ext(context); + // 隐私保护:当签到人数不满50人时的阈值 + const privacyThreshold = 50; + final totalUsers = rankState.leaderboard.length; + final isPrivacyMode = totalUsers < privacyThreshold && rankState.rankType == 'signin'; + return CupertinoPageScaffold( navigationBar: CupertinoNavigationBar( leading: const AdaptiveBackButton(), @@ -144,7 +151,11 @@ class _RankPageState extends ConsumerState { _buildTypeSelector(ext), if (rankState.currentSeason != null) _buildSeasonHeader(rankState.currentSeason!, ext), - _buildMyRank(rankState, ext), + // 隐私保护提示 + if (isPrivacyMode) ...[ + _buildPrivacyWarningBanner(ext), + ], + _buildMyRank(rankState, ext, isPrivacyMode: isPrivacyMode), Expanded( child: rankState.isLoading ? Center(child: CupertinoActivityIndicator(color: ext.textHint)) @@ -152,26 +163,28 @@ class _RankPageState extends ConsumerState { ? _buildErrorView(rankState, ext) : rankState.leaderboard.isEmpty ? _buildEmptyView(ext) - : ListView.builder( - padding: const EdgeInsets.only(top: 4, bottom: 40), - itemCount: rankState.leaderboard.length, - itemBuilder: (context, index) { - final item = rankState.leaderboard[index]; - final isTop3 = item.rank <= 3; - final canClaim = isTop3 && - !item.rewardClaimed && - rankState.currentSeason?.status == 'settled'; - return GestureDetector( - onTap: canClaim - ? () => _claimReward( - rankState.currentSeason!.id, - rankState.rankType, - ) - : null, - child: RankItemCard(item: item), - ); - }, - ), + : isPrivacyMode + ? _buildPrivacyListView(rankState, ext) + : ListView.builder( + padding: const EdgeInsets.only(top: 4, bottom: 40), + itemCount: rankState.leaderboard.length, + itemBuilder: (context, index) { + final item = rankState.leaderboard[index]; + final isTop3 = item.rank <= 3; + final canClaim = isTop3 && + !item.rewardClaimed && + rankState.currentSeason?.status == 'settled'; + return GestureDetector( + onTap: canClaim + ? () => _claimReward( + rankState.currentSeason!.id, + rankState.rankType, + ) + : null, + child: RankItemCard(item: item), + ); + }, + ), ), ], ), @@ -303,8 +316,265 @@ class _RankPageState extends ConsumerState { ); } - Widget _buildMyRank(RankState rankState, AppThemeExtension ext) { + /// 隐私保护提示横幅 + Widget _buildPrivacyWarningBanner(AppThemeExtension ext) { + return Container( + margin: const EdgeInsets.fromLTRB(16, 8, 16, 4), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + CupertinoColors.systemOrange.withValues(alpha: 0.1), + CupertinoColors.systemYellow.withValues(alpha: 0.05), + ], + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: CupertinoColors.systemOrange.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + children: [ + Icon( + CupertinoIcons.shield_fill, + size: 18, + color: CupertinoColors.systemOrange, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '量化数据不达标,为保障用户隐私,签到排名暂不展示。', + style: AppTypography.subhead.copyWith( + color: CupertinoColors.systemOrange, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } + + /// 隐私模式下的列表视图(只显示当前用户) + Widget _buildPrivacyListView(RankState rankState, AppThemeExtension ext) { + final authState = ref.watch(authProvider); + final currentUserId = authState.user?.id; + + // 查找当前用户在排行榜中的位置 + RankItem? myItem; + for (final item in rankState.leaderboard) { + if (item.userId == currentUserId) { + myItem = item; + break; + } + } + + if (myItem == null) { + // 当前用户不在排名中,显示空状态 + return _buildPrivacyEmptyView(ext); + } + + // 只显示当前用户自己的数据 + return ListView.builder( + padding: const EdgeInsets.only(top: 4, bottom: 40), + itemCount: 1, + itemBuilder: (context, index) { + return _buildPrivacyRankCard(myItem!, ext); + }, + ); + } + + /// 隐私模式下的空状态(用户未上榜) + Widget _buildPrivacyEmptyView(AppThemeExtension ext) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.shield, size: 48, color: ext.textHint), + const SizedBox(height: 12), + Text( + '隐私保护中', + style: AppTypography.headline.copyWith( + color: ext.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + '当前签到人数不足50人\n为保护用户隐私,暂不展示完整排行榜', + style: AppTypography.subhead.copyWith(color: ext.textHint), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: ext.overlaySubtle.withValues(alpha: 0.5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(CupertinoIcons.info_circle, size: 14, color: ext.accent), + const SizedBox(width: 6), + Text( + '继续努力签到,解锁完整排行榜', + style: AppTypography.caption1.copyWith(color: ext.accent), + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// 隐私模式下的用户卡片(排名用*代替) + Widget _buildPrivacyRankCard(RankItem item, AppThemeExtension ext) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ext.bgCard, ext.bgSecondary], + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: ext.accent.withValues(alpha: 0.3), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: ext.accent.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + // 排名序号(用*代替) + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ext.accent, ext.accentLight], + ), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: ext.accent.withValues(alpha: 0.3), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Center( + child: Text( + '*', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: CupertinoColors.white, + ), + ), + ), + ), + const SizedBox(width: 12), + // 头像 + CircleAvatar( + radius: 20, + backgroundColor: ext.bgSecondary, + backgroundImage: item.avatar.isNotEmpty + ? NetworkImage(item.avatar) + : null, + child: item.avatar.isEmpty + ? Icon( + CupertinoIcons.person_fill, + size: 24, + color: ext.textHint, + ) + : null, + ), + const SizedBox(width: 12), + // 用户信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + item.username.isNotEmpty ? item.username : '匿名用户', + style: AppTypography.subhead.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: ext.accent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '我', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: ext.accent, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + 'Lv.${item.level}', + style: AppTypography.caption1.copyWith(color: ext.textHint), + ), + ], + ), + ), + // 数值 + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${item.value}', + style: AppTypography.title3.copyWith( + fontWeight: FontWeight.bold, + color: ext.accent, + ), + ), + Text( + '次签到', + style: AppTypography.caption2.copyWith(color: ext.textHint), + ), + ], + ), + ], + ), + ); + } + + Widget _buildMyRank(RankState rankState, AppThemeExtension ext, {bool isPrivacyMode = false}) { final hasRank = rankState.myRank != null && rankState.myRank! > 0; + // 隐私模式下,排名显示为 * + final displayRank = isPrivacyMode && hasRank ? '*' : (hasRank ? '#${rankState.myRank}' : null); return Container( margin: const EdgeInsets.fromLTRB(16, 8, 16, 4), padding: const EdgeInsets.all(12), @@ -317,7 +587,7 @@ class _RankPageState extends ConsumerState { color: hasRank ? null : ext.bgCard, borderRadius: BorderRadius.circular(12), border: hasRank - ? Border.all(color: ext.accent.withValues(alpha: 0.2)) + ? Border.all(color: isPrivacyMode ? CupertinoColors.systemOrange.withValues(alpha: 0.3) : ext.accent.withValues(alpha: 0.2)) : Border.all(color: ext.overlaySubtle, width: 0.5), ), child: Row( @@ -326,12 +596,12 @@ class _RankPageState extends ConsumerState { const SizedBox(width: 8), Text('我的排名', style: AppTypography.subhead.copyWith(color: ext.textPrimary, fontWeight: FontWeight.w500)), const Spacer(), - if (hasRank) ...[ + if (displayRank != null) ...[ Text( - '#${rankState.myRank}', + displayRank, style: AppTypography.title3.copyWith( fontWeight: FontWeight.bold, - color: ext.accent, + color: isPrivacyMode ? CupertinoColors.systemOrange : ext.accent, ), ), const SizedBox(width: 8), diff --git a/lib/features/shared/presentation/qrcode_scanner_page.dart b/lib/features/shared/presentation/qrcode_scanner_page.dart new file mode 100644 index 00000000..62560832 --- /dev/null +++ b/lib/features/shared/presentation/qrcode_scanner_page.dart @@ -0,0 +1,931 @@ +/// ============================================================ +/// 闲言APP — 通用二维码扫描页面 +/// 创建时间: 2026-06-04 +/// 更新时间: 2026-06-04 +/// 作用: 提供完整的二维码扫描功能,支持URL/文本/vCard/WiFi等类型识别 +/// 上次更新: 初始版本,集成mobile_scanner实现真实扫码功能 +/// ============================================================ + +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart' show SelectableText, Colors; +import 'package:flutter/services.dart'; +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 '../../../core/theme/app_theme.dart'; +import '../../../core/theme/app_spacing.dart'; +import '../../../core/theme/app_typography.dart'; +import '../../../core/theme/app_radius.dart'; +import '../../../shared/widgets/feedback/app_toast.dart'; +import '../../../core/utils/logger.dart'; + +/// 二维码扫描结果类型 +enum QrCodeType { + url, + text, + email, + phone, + wifi, + vcard, + unknown, +} + +/// 扫描结果数据模型 +class QrScanResult { + final String rawValue; + final QrCodeType type; + final String? displayTitle; + final String? actionText; + + const QrScanResult({ + required this.rawValue, + required this.type, + this.displayTitle, + this.actionText, + }); +} + +class QrcodeScannerPage extends StatefulWidget { + const QrcodeScannerPage({super.key}); + + @override + State createState() => _QrcodeScannerPageState(); +} + +class _QrcodeScannerPageState extends State + with SingleTickerProviderStateMixin { + final MobileScannerController _scannerController = MobileScannerController( + autoStart: false, + detectionSpeed: DetectionSpeed.normal, + facing: CameraFacing.back, + ); + + bool _cameraPermissionDenied = false; + bool _isProcessing = false; + bool _torchEnabled = false; + bool _hasScanned = false; + late AnimationController _scanLineController; + final ImagePicker _imagePicker = ImagePicker(); + + @override + void initState() { + super.initState(); + _scanLineController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2000), + )..repeat(reverse: true); + + WidgetsBinding.instance.addPostFrameCallback((_) => _startScanner()); + } + + @override + void dispose() { + _scannerController.dispose(); + _scanLineController.dispose(); + super.dispose(); + } + + // ============================================================ + // 相机控制 + // ============================================================ + + Future _startScanner() async { + try { + await _scannerController.start(); + if (mounted) { + setState(() => _cameraPermissionDenied = false); + } + } catch (e) { + Log.w('相机启动失败: $e'); + if (mounted) { + setState(() => _cameraPermissionDenied = true); + } + } + } + + Future _toggleTorch() async { + try { + await _scannerController.toggleTorch(); + if (mounted) { + setState(() => _torchEnabled = !_torchEnabled); + } + } catch (e) { + Log.w('切换闪光灯失败: $e'); + AppToast.showWarning('闪光灯不可用'); + } + } + + // ============================================================ + // 从相册选择图片识别 + // ============================================================ + + Future _pickFromGallery() async { + try { + final XFile? image = await _imagePicker.pickImage( + source: ImageSource.gallery, + maxWidth: 1920, + maxHeight: 1920, + imageQuality: 85, + ); + + if (image == null) return; + + if (!mounted) return; + + AppToast.showInfo('正在识别二维码...'); + + final barcodeCapture = + await _scannerController.analyzeImage(image.path); + + if (!mounted) return; + + if (barcodeCapture == null || + barcodeCapture.barcodes.isEmpty || + barcodeCapture.barcodes.first.rawValue == null) { + AppToast.showWarning('未识别到二维码'); + return; + } + + final rawValue = barcodeCapture.barcodes.first.rawValue!; + _handleScanResult(rawValue); + } catch (e) { + Log.e('从相册识别失败', e); + if (mounted) { + AppToast.showError('识别失败: ${e.toString()}'); + } + } + } + + // ============================================================ + // 扫描结果处理 + // ============================================================ + + void _onDetect(BarcodeCapture capture) { + if (_isProcessing || _hasScanned) return; + + final barcode = capture.barcodes.firstOrNull; + if (barcode == null || barcode.rawValue == null) return; + + final rawValue = barcode.rawValue!; + + HapticFeedback.mediumImpact(); + + setState(() { + _isProcessing = true; + _hasScanned = true; + }); + + _scannerController.stop(); + + _handleScanResult(rawValue); + } + + void _handleScanResult(String rawValue) { + final result = _parseQrCode(rawValue); + + if (!mounted) return; + + _showResultDialog(result); + } + + /// 解析二维码内容并分类 + QrScanResult _parseQrCode(String rawValue) { + final trimmed = rawValue.trim(); + + // URL检测 + if (_isUrl(trimmed)) { + return QrScanResult( + rawValue: trimmed, + type: QrCodeType.url, + displayTitle: '链接', + actionText: '打开链接', + ); + } + + // Email检测 + final emailRegex = RegExp(r'^mailto:(.+@.+\..+)$', caseSensitive: false); + final emailMatch = emailRegex.firstMatch(trimmed); + if (emailMatch != null) { + return QrScanResult( + rawValue: trimmed, + type: QrCodeType.email, + displayTitle: '邮箱', + actionText: '发送邮件', + ); + } + + // 电话检测 + final phoneRegex = RegExp(r'^tel:(\+?[\d\s-]+)$'); + final phoneMatch = phoneRegex.firstMatch(trimmed); + if (phoneMatch != null) { + return QrScanResult( + rawValue: trimmed, + type: QrCodeType.phone, + displayTitle: '电话', + actionText: '拨打电话', + ); + } + + // WiFi检测(WIFI:S:ssid;T:WPA;P:password;;) + if (trimmed.startsWith('WIFI:') && trimmed.contains('S:')) { + return QrScanResult( + rawValue: trimmed, + type: QrCodeType.wifi, + displayTitle: 'WiFi网络', + actionText: '复制密码', + ); + } + + // vCard检测 + if (trimmed.toUpperCase().contains('BEGIN:VCARD')) { + return QrScanResult( + rawValue: trimmed, + type: QrCodeType.vcard, + displayTitle: '联系人', + actionText: '保存联系人', + ); + } + + // 纯文本 + return QrScanResult( + rawValue: trimmed, + type: QrCodeType.text, + displayTitle: '文本', + actionText: '复制文本', + ); + } + + bool _isUrl(String text) { + // 带协议的URL + if (text.startsWith('http://') || text.startsWith('https://')) { + return true; + } + + // 不带协议但符合域名格式 + final urlPattern = RegExp( + r'^[a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z]{2,}(/.*)?$', + ); + if (urlPattern.hasMatch(text)) { + return true; + } + + return false; + } + + // ============================================================ + // 结果展示与操作 + // ============================================================ + + void _showResultDialog(QrScanResult result) { + final ext = AppTheme.ext(context); + + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_getTypeIcon(result.type), size: 20, color: ext.accent), + const SizedBox(width: 8), + Expanded( + child: Text( + result.displayTitle ?? '扫描结果', + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + content: Padding( + padding: const EdgeInsets.only(top: AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.sm), + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: AppRadius.smBorder, + ), + child: SelectableText( + result.rawValue.length > 200 + ? '${result.rawValue.substring(0, 200)}...' + : result.rawValue, + style: AppTypography.caption1.copyWith( + color: ext.textSecondary, + ), + ), + ), + if (result.type == QrCodeType.wifi) + _buildWifiInfo(ext, result.rawValue), + ], + ), + ), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx), + isDestructiveAction: true, + child: Text('取消'), + ), + CupertinoDialogAction( + onPressed: () { + Navigator.pop(ctx); + _executeAction(result); + }, + isDefaultAction: true, + child: Text(result.actionText ?? '确定'), + ), + CupertinoDialogAction( + onPressed: () { + Navigator.pop(ctx); + Clipboard.setData(ClipboardData(text: result.rawValue)); + AppToast.showSuccess('已复制到剪贴板'); + }, + child: Text('复制'), + ), + ], + ), + ); + } + + Widget _buildWifiInfo(AppThemeExtension ext, String wifiData) { + final params = {}; + for (final segment in wifiData.split(';')) { + final parts = segment.split(':'); + if (parts.length >= 2) { + params[parts[0]] = parts.sublist(1).join(':'); + } + } + + final ssid = params['S'] ?? '未知网络'; + final security = params['T'] ?? '未知'; + + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.sm), + child: Row( + children: [ + Icon(CupertinoIcons.wifi, size: 16, color: ext.accent), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(ssid, style: AppTypography.body.copyWith(color: ext.textPrimary)), + Text( + '加密方式: $security', + style: AppTypography.caption2.copyWith(color: ext.textSecondary), + ), + ], + ), + ), + ], + ), + ); + } + + IconData _getTypeIcon(QrCodeType type) { + switch (type) { + case QrCodeType.url: + return CupertinoIcons.link; + case QrCodeType.email: + return CupertinoIcons.mail; + case QrCodeType.phone: + return CupertinoIcons.phone; + case QrCodeType.wifi: + return CupertinoIcons.wifi; + case QrCodeType.vcard: + return CupertinoIcons.person_crop_circle_badge_checkmark; + case QrCodeType.text: + return CupertinoIcons.doc_text; + case QrCodeType.unknown: + return CupertinoIcons.qrcode; + } + } + + Future _executeAction(QrScanResult result) async { + switch (result.type) { + case QrCodeType.url: + var url = result.rawValue; + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = 'https://$url'; + } + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + AppToast.showWarning('无法打开此链接'); + } + break; + + case QrCodeType.email: + final uri = Uri.parse(result.rawValue); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } + break; + + case QrCodeType.phone: + final uri = Uri.parse(result.rawValue); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } + break; + + case QrCodeType.wifi: + final params = {}; + for (final segment in result.rawValue.split(';')) { + final parts = segment.split(':'); + if (parts.length >= 2) { + params[parts[0]] = parts.sublist(1).join(':'); + } + } + final password = params['P'] ?? ''; + if (password.isNotEmpty) { + Clipboard.setData(ClipboardData(text: password)); + AppToast.showSuccess('WiFi密码已复制到剪贴板'); + } else { + AppToast.showInfo('该网络无密码'); + } + break; + + case QrCodeType.vcard: + Clipboard.setData(ClipboardData(text: result.rawValue)); + AppToast.showSuccess('vCard信息已复制,可导入通讯录'); + break; + + default: + Clipboard.setData(ClipboardData(text: result.rawValue)); + AppToast.showSuccess('已复制到剪贴板'); + break; + } + } + + // ============================================================ + // UI构建 + // ============================================================ + + @override + Widget build(BuildContext context) { + final ext = AppTheme.ext(context); + + return CupertinoPageScaffold( + backgroundColor: ext.bgPrimary, + navigationBar: CupertinoNavigationBar( + middle: Text( + '扫一扫', + style: AppTypography.title3.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + backgroundColor: ext.bgPrimary.withValues(alpha: 0.85), + border: null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CupertinoButton( + padding: EdgeInsets.zero, + onPressed: _pickFromGallery, + child: Icon( + CupertinoIcons.photo_on_rectangle, + color: ext.accent, + ), + ), + ], + ), + ), + child: SafeArea( + child: _cameraPermissionDenied + ? _buildPermissionDeniedView(ext) + : _buildScannerView(ext), + ), + ); + } + + Widget _buildScannerView(AppThemeExtension ext) { + return Stack( + children: [ + // 相机预览 + Positioned.fill( + child: MobileScanner( + controller: _scannerController, + onDetect: _onDetect, + ), + ), + + // 扫描框覆盖层 + Positioned.fill(child: _buildScanOverlay(ext)), + + // 处理中遮罩 + if (_isProcessing) + Positioned.fill( + child: Container( + color: ext.bgPrimary.withValues(alpha: 0.7), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CupertinoActivityIndicator(color: ext.accent, radius: 16), + const SizedBox(height: AppSpacing.md), + Text( + '正在识别...', + style: AppTypography.subhead.copyWith(color: ext.textSecondary), + ), + ], + ), + ), + ), + ), + + // 底部操作栏 + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _buildBottomBar(ext), + ), + ], + ); + } + + Widget _buildScanOverlay(AppThemeExtension ext) { + return LayoutBuilder( + builder: (context, constraints) { + final scanSize = constraints.maxWidth * 0.7; + final left = (constraints.maxWidth - scanSize) / 2; + final top = (constraints.maxHeight - scanSize) / 2 - 40; + final scanRect = Rect.fromLTWH(left, top, scanSize, scanSize); + + return Stack( + children: [ + // 半透明遮罩 + CustomPaint( + size: Size(constraints.maxWidth, constraints.maxHeight), + painter: _OverlayPainter(scanRect: scanRect, maskColor: Colors.black.withValues(alpha: 0.5)), + ), + + // 四角边框 + ..._buildCorners(scanRect, ext), + + // 扫描线动画 + Positioned( + left: scanRect.left + 4, + top: top + 4, + width: scanSize - 8, + height: scanSize - 8, + child: AnimatedBuilder( + animation: _scanLineController, + builder: (context, child) { + return Stack( + children: [ + Positioned( + top: _scanLineController.value * (scanSize - 8 - 2), + left: 0, + right: 0, + child: Container( + height: 2, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.transparent, + ext.accent, + Colors.transparent, + ], + ), + boxShadow: [ + BoxShadow( + color: ext.accent.withValues(alpha: 0.5), + blurRadius: 8, + ), + ], + ), + ), + ), + ], + ); + }, + ), + ), + + // 提示文字 + Positioned( + left: 0, + right: 0, + top: top + scanSize + AppSpacing.md, + child: Text( + '将二维码放入框内自动扫描', + textAlign: TextAlign.center, + style: AppTypography.footnote.copyWith(color: CupertinoColors.white), + ), + ), + ], + ); + }, + ); + } + + List _buildCorners(Rect scanRect, AppThemeExtension ext) { + const cornerLen = 24.0; + const cornerWidth = 3.0; + + return [ + // 左上角 + Positioned( + left: scanRect.left, + top: scanRect.top, + child: _CornerLine(direction: _CornerDirection.topLeft, color: ext.accent, length: cornerLen, width: cornerWidth), + ), + // 右上角 + Positioned( + right: scanRect.right, + top: scanRect.top, + child: _CornerLine(direction: _CornerDirection.topRight, color: ext.accent, length: cornerLen, width: cornerWidth), + ), + // 左下角 + Positioned( + left: scanRect.left, + bottom: scanRect.bottom, + child: _CornerLine(direction: _CornerDirection.bottomLeft, color: ext.accent, length: cornerLen, width: cornerWidth), + ), + // 右下角 + Positioned( + right: scanRect.right, + bottom: scanRect.bottom, + child: _CornerLine(direction: _CornerDirection.bottomRight, color: ext.accent, length: cornerLen, width: cornerWidth), + ), + ]; + } + + Widget _buildBottomBar(AppThemeExtension ext) { + return Container( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + MediaQuery.of(context).padding.bottom + AppSpacing.md, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.6), + ], + ), + ), + child: SafeArea( + top: false, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _BottomActionButton( + icon: CupertinoIcons.lightbulb_fill, + label: '闪光灯', + isActive: _torchEnabled, + onTap: _toggleTorch, + ), + _BottomActionButton( + icon: CupertinoIcons.photo_on_rectangle, + label: '相册', + isActive: false, + onTap: _pickFromGallery, + ), + if (_hasScanned) + _BottomActionButton( + icon: CupertinoIcons.arrow_clockwise, + label: '继续扫描', + isActive: false, + onTap: () { + setState(() { + _hasScanned = false; + _isProcessing = false; + }); + _startScanner(); + }, + ), + ], + ), + ), + ); + } + + Widget _buildPermissionDeniedView(AppThemeExtension ext) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: ext.accent.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon( + CupertinoIcons.camera, + size: 48, + color: ext.accent, + ), + ) + .animate() + .fadeIn(duration: 400.ms) + .scale(begin: const Offset(0.8, 0.8), end: const Offset(1.0, 1.0), duration: 400.ms), + const SizedBox(height: AppSpacing.xl), + Text( + '需要相机权限', + style: AppTypography.title2.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + '请在系统设置中开启相机权限,以使用扫码功能', + style: AppTypography.subhead.copyWith(color: ext.textHint), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.xl), + CupertinoButton( + color: ext.accent, + borderRadius: AppRadius.lgBorder, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl, vertical: AppSpacing.md), + onPressed: _startScanner, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(CupertinoIcons.refresh, size: 18, color: CupertinoColors.white), + const SizedBox(width: AppSpacing.sm), + Text( + '重新授权', + style: AppTypography.subhead.copyWith( + color: CupertinoColors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +// ============================================================ +// 自定义组件 +// ============================================================ + +/// 遮罩绘制器 +class _OverlayPainter extends CustomPainter { + const _OverlayPainter({required this.scanRect, required this.maskColor}); + + final Rect scanRect; + final Color maskColor; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = maskColor; + + final path = Path() + ..addRect(Rect.fromLTWH(0, 0, size.width, size.height)) + ..addRRect(RRect.fromRectAndRadius(scanRect, Radius.zero)); + + path.fillType = PathFillType.evenOdd; + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant _OverlayPainter oldDelegate) => + scanRect != oldDelegate.scanRect || maskColor != oldDelegate.maskColor; +} + +/// 角落方向枚举 +enum _CornerDirection { topLeft, topRight, bottomLeft, bottomRight } + +/// 角落线条组件 +class _CornerLine extends StatelessWidget { + const _CornerLine({ + required this.direction, + required this.color, + required this.length, + required this.width, + }); + + final _CornerDirection direction; + final Color color; + final double length; + final double width; + + @override + Widget build(BuildContext context) { + return CustomPaint( + size: Size(length, length), + painter: _CornerPainter(direction: direction, color: color, strokeWidth: width), + ); + } +} + +/// 角落绘制器 +class _CornerPainter extends CustomPainter { + const _CornerPainter({ + required this.direction, + required this.color, + required this.strokeWidth, + }); + + final _CornerDirection direction; + final Color color; + final double strokeWidth; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + + switch (direction) { + case _CornerDirection.topLeft: + canvas.drawLine(Offset.zero, Offset(size.width, 0), paint); + canvas.drawLine(Offset.zero, Offset(0, size.height), paint); + break; + case _CornerDirection.topRight: + canvas.drawLine(Offset(size.width, 0), Offset.zero, paint); + canvas.drawLine(Offset(size.width, 0), Offset(size.width, size.height), paint); + break; + case _CornerDirection.bottomLeft: + canvas.drawLine(Offset(0, size.height), Offset.zero, paint); + canvas.drawLine(Offset(0, size.height), Offset(size.width, size.height), paint); + break; + case _CornerDirection.bottomRight: + canvas.drawLine(Offset(size.width, size.height), Offset(0, size.height), paint); + canvas.drawLine(Offset(size.width, size.height), Offset(size.width, 0), paint); + break; + } + } + + @override + bool shouldRepaint(covariant _CornerPainter oldDelegate) => + direction != oldDelegate.direction || color != oldDelegate.color; +} + +/// 底部操作按钮 +class _BottomActionButton extends StatelessWidget { + const _BottomActionButton({ + required this.icon, + required this.label, + required this.onTap, + this.isActive = false, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool isActive; + + @override + Widget build(BuildContext context) { + final ext = AppTheme.ext(context); + + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive ? ext.accent.withValues(alpha: 0.3) : Colors.white.withValues(alpha: 0.2), + border: Border.all( + color: isActive ? ext.accent : Colors.white.withValues(alpha: 0.5), + width: 1.5, + ), + ), + child: Icon(icon, size: 24, color: isActive ? ext.accent : CupertinoColors.white), + ), + const SizedBox(height: AppSpacing.xs), + Text( + label, + style: AppTypography.caption2.copyWith(color: CupertinoColors.white), + ), + ], + ), + ); + } +} diff --git a/lib/features/task/presentation/daily_task_page.dart b/lib/features/task/presentation/daily_task_page.dart index 59cce27c..a6c28f11 100644 --- a/lib/features/task/presentation/daily_task_page.dart +++ b/lib/features/task/presentation/daily_task_page.dart @@ -35,7 +35,7 @@ class _DailyTaskPageState extends ConsumerState { return CupertinoPageScaffold( navigationBar: const CupertinoNavigationBar( leading: AdaptiveBackButton(), - middle: Text('📋 每日任务'), + middle: const Text('每日任务'), ), child: SafeArea( child: taskState.isLoading @@ -105,9 +105,9 @@ class _DailyTaskPageState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _buildStatItem('📋', '总任务', summary.total, ext), - _buildStatItem('✅', '已完成', summary.completed, ext), - _buildStatItem('🎁', '已领取', summary.claimed, ext), + _buildStatItem(CupertinoIcons.list_bullet, '总任务', summary.total, ext), + _buildStatItem(CupertinoIcons.checkmark_circle_fill, '已完成', summary.completed, ext), + _buildStatItem(CupertinoIcons.gift_fill, '已领取', summary.claimed, ext), ], ), const SizedBox(height: 12), @@ -126,13 +126,19 @@ class _DailyTaskPageState extends ConsumerState { ), if (summary.isPerfectDay) ...[ const SizedBox(height: 8), - Text( - '🎉 完美日!所有任务已完成', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: ext.warningColor, - ), + Row( + children: [ + Icon(CupertinoIcons.sparkles, size: 16, color: ext.warningColor), + const SizedBox(width: 6), + Text( + '完美日!所有任务已完成', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: ext.warningColor, + ), + ), + ], ), ], ], @@ -142,14 +148,14 @@ class _DailyTaskPageState extends ConsumerState { } Widget _buildStatItem( - String emoji, + IconData icon, String label, int value, AppThemeExtension ext, ) { return Column( children: [ - Text(emoji, style: const TextStyle(fontSize: 22)), + Icon(icon, size: 22, color: ext.textSecondary), const SizedBox(height: 4), Text( '$value', @@ -185,13 +191,19 @@ class _DailyTaskPageState extends ConsumerState { ), child: Column( children: [ - const Text( - '🌟 完美日奖励', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: CupertinoColors.white, - ), + Row( + children: [ + Icon(CupertinoIcons.star_fill, size: 20, color: CupertinoColors.white), + const SizedBox(width: 8), + Text( + '完美日奖励', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: CupertinoColors.white, + ), + ), + ], ), const SizedBox(height: 8), const Text( @@ -200,7 +212,7 @@ class _DailyTaskPageState extends ConsumerState { ), const SizedBox(height: 4), const Text( - '+20 💎 +10 ⭐', + '+20 经验 +10 积分', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -214,7 +226,7 @@ class _DailyTaskPageState extends ConsumerState { color: CupertinoColors.white, onPressed: _claimPerfectDay, child: const Text( - '🎊 领取完美日奖励', + '领取完美日奖励', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, @@ -248,12 +260,12 @@ class _DailyTaskPageState extends ConsumerState { showCupertinoDialog( context: context, builder: (ctx) => CupertinoAlertDialog( - title: Text('🎉 $name'), + title: Text('$name 奖励'), content: Column( children: [ const SizedBox(height: 8), - if (expReward > 0) Text('+$expReward 💎 经验值'), - if (scoreReward > 0) Text('+$scoreReward ⭐ 积分'), + if (expReward > 0) Text('+$expReward 经验值'), + if (scoreReward > 0) Text('+$scoreReward 积分'), ], ), actions: [ diff --git a/lib/l10n/languages/ar.dart b/lib/l10n/languages/ar.dart index 6a6a3ef7..f6997bb8 100644 --- a/lib/l10n/languages/ar.dart +++ b/lib/l10n/languages/ar.dart @@ -1823,6 +1823,9 @@ const ar = T( allChannelsCached: 'جميع القنوات مخزنة مسبقاً', preloadFailed: 'فشل التحميل المسبق، تحقق من الاتصال', preloadError: 'خطأ في التحميل المسبق: {error}', + preloadModeDisabledHint: 'وضع التحميل المسبق معطل', + wifiOnlyModeHint: 'وضع WiFi فقط مفعّل', + preloadNoNewContent: 'لا يوجد محتوى جديد للتحميل المسبق', ), accountInsights: TAccountInsights( title: 'رؤى الحساب', diff --git a/lib/l10n/languages/bn.dart b/lib/l10n/languages/bn.dart index b34754cf..a39bd1f4 100644 --- a/lib/l10n/languages/bn.dart +++ b/lib/l10n/languages/bn.dart @@ -1833,6 +1833,9 @@ const bn = T( allChannelsCached: 'সব চ্যানেল ইতিমধ্যে ক্যাশ', preloadFailed: 'প্রিলোড ব্যর্থ, সংযোগ পরীক্ষা করুন', preloadError: 'প্রিলোড ত্রুটি: {error}', + preloadModeDisabledHint: 'প্রিলোড মোড নিষ্ক্রিয় করা হয়েছে', + wifiOnlyModeHint: 'শুধুমাত্র Wi-Fi মোড সক্রিয়', + preloadNoNewContent: 'প্রিলোড করার জন্য নতুন কোনো বিষয়বস্তু নেই', ), accountInsights: TAccountInsights( title: 'অ্যাকাউন্ট ইনসাইট', diff --git a/lib/l10n/languages/de.dart b/lib/l10n/languages/de.dart index 3c96ce7a..0b86af2b 100644 --- a/lib/l10n/languages/de.dart +++ b/lib/l10n/languages/de.dart @@ -1855,6 +1855,9 @@ const de = T( allChannelsCached: 'Alle Kanäle sind bereits gecacht', preloadFailed: 'Vorladen fehlgeschlagen, Verbindung prüfen', preloadError: 'Vorlade-Fehler: {error}', + preloadModeDisabledHint: 'Vorlademodus deaktiviert', + wifiOnlyModeHint: 'Nur-WiFi-Modus aktiviert', + preloadNoNewContent: 'Keine neuen Inhalte zum Vorladen', ), accountInsights: TAccountInsights( title: 'Konto-Einblicke', diff --git a/lib/l10n/languages/en.dart b/lib/l10n/languages/en.dart index d8eccca5..aa706a30 100644 --- a/lib/l10n/languages/en.dart +++ b/lib/l10n/languages/en.dart @@ -1844,6 +1844,9 @@ const en = T( allChannelsCached: 'All channels already cached', preloadFailed: 'Preload failed, please check your connection', preloadError: 'Preload error: {error}', + preloadModeDisabledHint: 'Preload mode is disabled, enable it in smart preload strategy', + wifiOnlyModeHint: 'WiFi-only mode active, please connect to WiFi', + preloadNoNewContent: 'No new content to preload, all up-to-date or rate limited', ), accountInsights: TAccountInsights( title: 'Account Insights', diff --git a/lib/l10n/languages/es.dart b/lib/l10n/languages/es.dart index f281654d..9b8e730a 100644 --- a/lib/l10n/languages/es.dart +++ b/lib/l10n/languages/es.dart @@ -1869,6 +1869,9 @@ const es = T( allChannelsCached: 'Todos los canales ya están en caché', preloadFailed: 'Precarga fallida, verifica tu conexión', preloadError: 'Error de precarga: {error}', + preloadModeDisabledHint: 'Modo de precarga desactivado', + wifiOnlyModeHint: 'Modo solo WiFi activado', + preloadNoNewContent: 'No hay contenido nuevo para precargar', ), accountInsights: TAccountInsights( title: 'Información de la cuenta', diff --git a/lib/l10n/languages/fr.dart b/lib/l10n/languages/fr.dart index 385c72c7..cff7ee3c 100644 --- a/lib/l10n/languages/fr.dart +++ b/lib/l10n/languages/fr.dart @@ -1876,6 +1876,9 @@ const fr = T( allChannelsCached: 'Tous les canaux sont déjà en cache', preloadFailed: 'Préchargement échoué, vérifiez votre connexion', preloadError: 'Erreur de préchargement : {error}', + preloadModeDisabledHint: 'Mode de préchargement désactivé', + wifiOnlyModeHint: 'Mode WiFi uniquement activé', + preloadNoNewContent: 'Aucun nouveau contenu à précharger', ), accountInsights: TAccountInsights( title: 'Aperçu du compte', diff --git a/lib/l10n/languages/hi.dart b/lib/l10n/languages/hi.dart index a2133781..77a74023 100644 --- a/lib/l10n/languages/hi.dart +++ b/lib/l10n/languages/hi.dart @@ -1828,6 +1828,9 @@ const hi = T( allChannelsCached: 'सभी चैनल पहले से कैश हैं', preloadFailed: 'प्रीलोड विफल, कनेक्शन जाँचें', preloadError: 'प्रीलोड त्रुटि: {error}', + preloadModeDisabledHint: 'प्रीलोड मोड बंद है', + wifiOnlyModeHint: 'केवल WiFi मोड सक्षम', + preloadNoNewContent: 'प्रीलोड करने के लिए कोई नया सामग्री नहीं', ), accountInsights: TAccountInsights( title: 'खाता अंतर्दृष्टि', diff --git a/lib/l10n/languages/it.dart b/lib/l10n/languages/it.dart index 4bc8cf53..180e7235 100644 --- a/lib/l10n/languages/it.dart +++ b/lib/l10n/languages/it.dart @@ -1862,6 +1862,9 @@ const it = T( allChannelsCached: 'Tutti i canali già in cache', preloadFailed: 'Precaricamento fallito, verifica la connessione', preloadError: 'Errore precaricamento: {error}', + preloadModeDisabledHint: 'Modalità precaricamento disattivata', + wifiOnlyModeHint: 'Modalità solo WiFi attivata', + preloadNoNewContent: 'Nessun nuovo contenuto da precaricare', ), accountInsights: TAccountInsights( title: 'Approfondimenti account', diff --git a/lib/l10n/languages/ja.dart b/lib/l10n/languages/ja.dart index 739264d9..016513e7 100644 --- a/lib/l10n/languages/ja.dart +++ b/lib/l10n/languages/ja.dart @@ -1774,6 +1774,9 @@ const ja = T( allChannelsCached: 'すべてのチャンネルはキャッシュ済みです', preloadFailed: 'プリロード失敗、ネットワーク接続を確認してください', preloadError: 'プリロードエラー:{error}', + preloadModeDisabledHint: 'プリロードモードが無効です', + wifiOnlyModeHint: 'WiFiのみモードが有効です', + preloadNoNewContent: 'プリロードする新しいコンテンツはありません', ), accountInsights: TAccountInsights( title: 'アカウントインサイト', diff --git a/lib/l10n/languages/ko.dart b/lib/l10n/languages/ko.dart index 8f29513e..ee6ba168 100644 --- a/lib/l10n/languages/ko.dart +++ b/lib/l10n/languages/ko.dart @@ -1774,6 +1774,9 @@ const ko = T( allChannelsCached: '모든 채널이 이미 캐시되어 있습니다', preloadFailed: '사전 로드 실패, 연결을 확인해 주세요', preloadError: '사전 로드 오류: {error}', + preloadModeDisabledHint: '프리로드 모드가 비활성화되었습니다', + wifiOnlyModeHint: 'WiFi 전용 모드가 활성화되었습니다', + preloadNoNewContent: '프리로드할 새 콘텐츠가 없습니다', ), accountInsights: TAccountInsights( title: '계정 인사이트', diff --git a/lib/l10n/languages/pt.dart b/lib/l10n/languages/pt.dart index 0d7e8808..6578c3b2 100644 --- a/lib/l10n/languages/pt.dart +++ b/lib/l10n/languages/pt.dart @@ -1857,6 +1857,9 @@ const pt = T( allChannelsCached: 'Todos os canais já estão em cache', preloadFailed: 'Pré-carregamento falhou, verifique sua conexão', preloadError: 'Erro de pré-carregamento: {error}', + preloadModeDisabledHint: 'Modo de pré-carregamento desativado', + wifiOnlyModeHint: 'Modo apenas WiFi ativado', + preloadNoNewContent: 'Sem novo conteúdo para pré-carregar', ), accountInsights: TAccountInsights( title: 'Insights da Conta', diff --git a/lib/l10n/languages/ru.dart b/lib/l10n/languages/ru.dart index f2aeb938..ac914daa 100644 --- a/lib/l10n/languages/ru.dart +++ b/lib/l10n/languages/ru.dart @@ -1850,6 +1850,9 @@ const ru = T( allChannelsCached: 'Все каналы уже в кэше', preloadFailed: 'Предзагрузка не удалась, проверьте подключение', preloadError: 'Ошибка предзагрузки: {error}', + preloadModeDisabledHint: 'Режим предварительной загрузки отключен', + wifiOnlyModeHint: 'Включен режим только WiFi', + preloadNoNewContent: 'Нет нового контента для предзагрузки', ), accountInsights: TAccountInsights( title: 'Аналитика аккаунта', diff --git a/lib/l10n/languages/zh_cn.dart b/lib/l10n/languages/zh_cn.dart index b0df4c01..fd6c13d7 100644 --- a/lib/l10n/languages/zh_cn.dart +++ b/lib/l10n/languages/zh_cn.dart @@ -12,7 +12,7 @@ const zhCN = T( nav: TNav( home: '闲言', discover: '发现', - profile: '我的', + profile: '个人中心', footprint: '足迹', inspiration: '灵感', ), @@ -1761,6 +1761,9 @@ const zhCN = T( allChannelsCached: '所有频道已有缓存,无需预加载', preloadFailed: '预加载失败,请检查网络连接后重试', preloadError: '预加载异常:{error}', + preloadModeDisabledHint: '当前预加载模式已关闭,请在智能预加载策略中开启', + wifiOnlyModeHint: '当前为仅WiFi模式,请连接WiFi后重试', + preloadNoNewContent: '当前无需预加载,所有内容已是最新或频率限制', ), accountInsights: TAccountInsights( title: '账户洞察', diff --git a/lib/l10n/languages/zh_tw.dart b/lib/l10n/languages/zh_tw.dart index 2b6ca61c..e2e53f50 100644 --- a/lib/l10n/languages/zh_tw.dart +++ b/lib/l10n/languages/zh_tw.dart @@ -1760,6 +1760,9 @@ const zhTW = T( allChannelsCached: '所有頻道已有快取,無需預載入', preloadFailed: '預載入失敗,請檢查網路連線後重試', preloadError: '預載入異常:{error}', + preloadModeDisabledHint: '預載入模式已關閉', + wifiOnlyModeHint: '目前為僅WiFi模式', + preloadNoNewContent: '沒有新內容需要預載入', ), accountInsights: TAccountInsights( title: '帳號洞察', diff --git a/lib/l10n/types/t_offline.dart b/lib/l10n/types/t_offline.dart index 91253203..40309138 100644 --- a/lib/l10n/types/t_offline.dart +++ b/lib/l10n/types/t_offline.dart @@ -64,6 +64,9 @@ class TOffline { required this.allChannelsCached, required this.preloadFailed, required this.preloadError, + required this.preloadModeDisabledHint, + required this.wifiOnlyModeHint, + required this.preloadNoNewContent, }); /// 页面标题 @@ -178,6 +181,12 @@ class TOffline { final String preloadFailed; /// 预加载异常:{error} final String preloadError; + /// 当前预加载模式已关闭,请在智能预加载策略中开启 + final String preloadModeDisabledHint; + /// 当前为仅WiFi模式,请连接WiFi后重试 + final String wifiOnlyModeHint; + /// 当前无需预加载,所有内容已是最新或频率限制 + final String preloadNoNewContent; Map toMap() => { 'title': title, @@ -236,6 +245,9 @@ class TOffline { 'allChannelsCached': allChannelsCached, 'preloadFailed': preloadFailed, 'preloadError': preloadError, + 'preloadModeDisabledHint': preloadModeDisabledHint, + 'wifiOnlyModeHint': wifiOnlyModeHint, + 'preloadNoNewContent': preloadNoNewContent, }; static TOffline fromMap(Map map, {TOffline? fallback}) => @@ -296,5 +308,8 @@ class TOffline { allChannelsCached: map['allChannelsCached']?.isNotEmpty == true ? map['allChannelsCached']! : (fallback?.allChannelsCached ?? ''), preloadFailed: map['preloadFailed']?.isNotEmpty == true ? map['preloadFailed']! : (fallback?.preloadFailed ?? ''), preloadError: map['preloadError']?.isNotEmpty == true ? map['preloadError']! : (fallback?.preloadError ?? ''), + preloadModeDisabledHint: map['preloadModeDisabledHint']?.isNotEmpty == true ? map['preloadModeDisabledHint']! : (fallback?.preloadModeDisabledHint ?? ''), + wifiOnlyModeHint: map['wifiOnlyModeHint']?.isNotEmpty == true ? map['wifiOnlyModeHint']! : (fallback?.wifiOnlyModeHint ?? ''), + preloadNoNewContent: map['preloadNoNewContent']?.isNotEmpty == true ? map['preloadNoNewContent']! : (fallback?.preloadNoNewContent ?? ''), ); } diff --git a/lib/shared/widgets/interaction/animated_action_button.dart b/lib/shared/widgets/interaction/animated_action_button.dart index 45c25b70..db8a3d4d 100644 --- a/lib/shared/widgets/interaction/animated_action_button.dart +++ b/lib/shared/widgets/interaction/animated_action_button.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 通用动画操作按钮组件 /// 创建时间: 2026-06-01 -/// 更新时间: 2026-06-01 -/// 作用: 带缩放动画和激活态切换的操作按钮,支持自定义emoji映射 -/// 上次更新: 从sentence_detail_actions.dart提取 +/// 更新时间: 2026-06-04 +/// 作用: 带缩放动画和激活态切换的操作按钮,支持IconData或emoji +/// 上次更新: 新增icon参数支持CupertinoIcons,逐步替换emoji混用 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -18,30 +18,29 @@ import '../../../core/theme/app_radius.dart'; class AnimatedActionButton extends StatefulWidget { const AnimatedActionButton({ - required this.emoji, + this.icon, + this.emoji, required this.label, required this.isActive, required this.ext, required this.onTap, - this.activeEmojiMap, + this.activeIcon, super.key, }); - final String emoji; + /// CupertinoIcons图标(优先使用) + final IconData? icon; + + /// Emoji文本(向后兼容) + final String? emoji; + final String label; final bool isActive; final AppThemeExtension ext; final VoidCallback onTap; - /// 激活态emoji映射,key为原始emoji,value为激活后的emoji - final Map? activeEmojiMap; - - /// 默认激活态emoji映射 - static const Map defaultActiveEmojiMap = { - '👍': '❤️', - '⭐': '🌟', - '📖': '✅', - }; + /// 激活态图标映射 + final IconData? activeIcon; @override State createState() => _AnimatedActionButtonState(); @@ -76,7 +75,10 @@ class _AnimatedActionButtonState extends State Widget build(BuildContext context) { final isActive = widget.isActive; final ext = widget.ext; - final activeEmoji = isActive ? _getActiveEmoji(widget.emoji) : widget.emoji; + final useIcon = widget.icon != null; + final displayIcon = isActive && widget.activeIcon != null + ? widget.activeIcon! + : widget.icon; return GestureDetector( onTapDown: (_) => _controller.forward(), @@ -109,11 +111,9 @@ class _AnimatedActionButtonState extends State children: [ AnimatedSwitcher( duration: const Duration(milliseconds: 200), - child: Text( - activeEmoji, - key: ValueKey(activeEmoji), - style: const TextStyle(fontSize: 22), - ), + child: useIcon + ? Icon(displayIcon!, key: ValueKey(displayIcon), size: 22, color: isActive ? ext.accent : ext.textSecondary) + : Text(widget.emoji ?? '', key: ValueKey(widget.emoji), style: const TextStyle(fontSize: 22)), ), const SizedBox(height: 4), AnimatedDefaultTextStyle( @@ -130,10 +130,4 @@ class _AnimatedActionButtonState extends State ), ); } - - /// 根据emoji映射获取激活态emoji - String _getActiveEmoji(String emoji) { - final map = widget.activeEmojiMap ?? AnimatedActionButton.defaultActiveEmojiMap; - return map[emoji] ?? emoji; - } } diff --git a/lib/shared/widgets/media/tts_player_bar.dart b/lib/shared/widgets/media/tts_player_bar.dart index 826853da..10e0fdd4 100644 --- a/lib/shared/widgets/media/tts_player_bar.dart +++ b/lib/shared/widgets/media/tts_player_bar.dart @@ -1,12 +1,13 @@ // ============================================================ -// 闲言APP — TTS朗读播放条组件 +// 闲言APP — TTS朗读播放条组件(增强版) // 创建时间: 2026-05-20 -// 更新时间: 2026-05-20 -// 作用: 句子详情Sheet中的朗读控制条 -// 上次更新: 初始版本 +// 更新时间: 2026-06-04 +// 作用: 句子详情Sheet中的朗读控制条,支持语速调节+动画波形 +// 上次更新: 新增语速调节滑块、动画波形指示器、多语言支持 // ============================================================ import 'dart:async'; +import 'dart:math'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -17,25 +18,57 @@ import 'package:xianyan/core/theme/app_typography.dart'; import 'package:xianyan/core/theme/app_radius.dart'; class TtsPlayerBar extends StatefulWidget { - const TtsPlayerBar({super.key, required this.text}); + const TtsPlayerBar({ + super.key, + required this.text, + this.author, + this.languageCode = 'zh', + }); final String text; + final String? author; + final String languageCode; @override State createState() => _TtsPlayerBarState(); } -class _TtsPlayerBarState extends State { +class _TtsPlayerBarState extends State + with SingleTickerProviderStateMixin { StreamSubscription? _stateSub; StreamSubscription<(int, int, String)>? _progressSub; double _progress = 0.0; bool _isPlaying = false; + bool _isPaused = false; + bool _showSpeedControl = false; + double _currentSpeed = 0.5; + + // 波形动画控制器 + late AnimationController _waveController; + + // 语速选项 + static const List> _speedOptions = [ + MapEntry(0.25, '0.25x'), + MapEntry(0.5, '0.5x'), + MapEntry(0.75, '0.75x'), + MapEntry(1.0, '1.0x'), + MapEntry(1.25, '1.25x'), + MapEntry(1.5, '1.5x'), + MapEntry(1.75, '1.75x'), + MapEntry(2.0, '2.0x'), + ]; @override void initState() { super.initState(); + _waveController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + )..repeat(reverse: true); + final tts = TtsService.instance; _isPlaying = tts.isSpeaking; + _currentSpeed = 0.5; // 默认值,实际从服务获取 _stateSub = tts.onStateChanged.listen(_onStateChanged); _progressSub = tts.onProgress.listen(_onProgress); } @@ -44,10 +77,20 @@ class _TtsPlayerBarState extends State { if (!mounted) return; setState(() { _isPlaying = state == TtsState.speaking; + _isPaused = state == TtsState.paused; if (state == TtsState.idle) { _progress = 0.0; + _showSpeedControl = false; } }); + + // 控制波形动画 + if (state == TtsState.speaking) { + _waveController.repeat(reverse: true); + } else { + _waveController.stop(); + _waveController.reset(); + } } void _onProgress((int, int, String) data) { @@ -60,12 +103,22 @@ class _TtsPlayerBarState extends State { } } - void _togglePlay() { + void _togglePlay() async { final tts = TtsService.instance; + + // 设置语言 + final ttsLang = TtsService.mapToTtsLanguage(widget.languageCode); + await tts.setLanguage(ttsLang); + if (_isPlaying) { - tts.pause(); + await tts.pause(); } else { - tts.speak(widget.text); + // 构建朗读文本(包含作者) + String speakText = widget.text; + if (widget.author != null && widget.author!.isNotEmpty) { + speakText = '${widget.text}。${widget.author}'; + } + await tts.speak(speakText); } } @@ -73,10 +126,16 @@ class _TtsPlayerBarState extends State { TtsService.instance.stop(); } + void _setSpeed(double speed) async { + setState(() => _currentSpeed = speed); + await TtsService.instance.setSpeed(speed); + } + @override void dispose() { _stateSub?.cancel(); _progressSub?.cancel(); + _waveController.dispose(); super.dispose(); } @@ -93,60 +152,223 @@ class _TtsPlayerBarState extends State { color: ext.bgSecondary, borderRadius: AppRadius.lgBorder, ), - child: Row( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - GestureDetector( - onTap: _togglePlay, - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: ext.accent, - shape: BoxShape.circle, - ), - child: Icon( - _isPlaying - ? CupertinoIcons.pause_fill - : CupertinoIcons.play_fill, - color: CupertinoColors.white, - size: 18, - ), - ), - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: AppRadius.fullBorder, - child: LinearProgressIndicator( - value: _progress, - backgroundColor: ext.overlaySubtle, - valueColor: AlwaysStoppedAnimation(ext.accent), - minHeight: 3, + // 主控制行 + Row( + children: [ + // 播放/暂停按钮 + GestureDetector( + onTap: _togglePlay, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: ext.accent, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: ext.accent.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon( + _isPaused + ? CupertinoIcons.play_fill + : _isPlaying + ? CupertinoIcons.pause_fill + : CupertinoIcons.play_fill, + color: CupertinoColors.white, + size: 18, ), ), - const SizedBox(height: 4), - Text( - _isPlaying ? '正在朗读...' : (_progress > 0 ? '已暂停' : '点击播放'), - style: AppTypography.caption2.copyWith(color: ext.textHint), - ), - ], - ), - ), - if (_isPlaying || _progress > 0) - GestureDetector( - onTap: _stop, - child: Padding( - padding: const EdgeInsets.all(8), - child: Icon( - CupertinoIcons.stop_fill, - size: 20, - color: ext.iconSecondary, + ), + const SizedBox(width: AppSpacing.sm), + + // 进度条 + 状态文本 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 进度条或波形动画 + ClipRRect( + borderRadius: AppRadius.fullBorder, + child: _isPlaying + ? _buildWaveform(ext) + : LinearProgressIndicator( + value: _progress > 0 ? _progress : null, + backgroundColor: ext.overlaySubtle, + valueColor: + AlwaysStoppedAnimation(ext.accent), + minHeight: 4, + ), + ), + const SizedBox(height: 4), + // 状态文本 + Row( + children: [ + Text( + _getStatusText(), + style: AppTypography.caption2 + .copyWith(color: ext.textHint), + ), + const Spacer(), + // 语速显示 + if (_isPlaying || _isPaused || _progress > 0) + GestureDetector( + onTap: () => setState(() => + _showSpeedControl = !_showSpeedControl), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: ext.bgElevated, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${_currentSpeed.toStringAsFixed(2)}x', + style: AppTypography.caption2.copyWith( + color: ext.accent, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ], ), ), + + // 停止按钮 + if (_isPlaying || _isPaused || _progress > 0) + GestureDetector( + onTap: _stop, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + CupertinoIcons.stop_fill, + size: 20, + color: ext.iconSecondary, + ), + ), + ), + ], + ), + + // 语速控制面板 + if (_showSpeedControl) ...[ + const SizedBox(height: AppSpacing.sm), + _buildSpeedControl(ext), + ], + ], + ), + ); + } + + /// 构建状态文本 + String _getStatusText() { + if (_isPlaying) return '正在朗读...'; + if (_isPaused) return '已暂停'; + if (_progress > 0 && _progress < 1.0) return '已暂停'; + return '点击播放'; + } + + /// 构建动画波形 + Widget _buildWaveform(AppThemeExtension ext) { + return SizedBox( + height: 16, + child: AnimatedBuilder( + animation: _waveController, + builder: (context, _) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.center, + children: List.generate(12, (index) { + // 每个柱子有不同的相位偏移 + final phase = (index / 12) * 2 * pi; + final value = _waveController.value; + final height = + 4 + 12 * ((sin(phase + value * 2 * pi) + 1) / 2); + + return AnimatedContainer( + duration: Duration(milliseconds: 100 + index * 10), + width: 3, + height: height, + decoration: BoxDecoration( + color: ext.accent.withValues( + alpha: 0.6 + 0.4 * ((sin(phase + value * 2 * pi) + 1) / 2), + ), + borderRadius: BorderRadius.circular(2), + ), + ); + }), + ); + }, + ), + ); + } + + /// 构建语速控制面板 + Widget _buildSpeedControl(AppThemeExtension ext) { + return Container( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), + child: Column( + children: [ + Text( + '语速调节', + style: AppTypography.caption2.copyWith(color: ext.textHint), + ), + const SizedBox(height: AppSpacing.xs), + SizedBox( + height: 32, + child: CupertinoSlider( + value: _currentSpeed.clamp(0.25, 2.0), + min: 0.25, + max: 2.0, + divisions: 14, + onChanged: _setSpeed, + activeColor: ext.accent, ), + ), + // 快捷速度选项 + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: _speedOptions.map((entry) { + final isSelected = (_currentSpeed - entry.key).abs() < 0.05; + return GestureDetector( + onTap: () => _setSpeed(entry.key), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: isSelected + ? ext.accent.withValues(alpha: 0.15) + : Colors.transparent, + borderRadius: BorderRadius.circular(4), + border: isSelected + ? Border.all(color: ext.accent, width: 1) + : null, + ), + child: Text( + entry.value, + style: AppTypography.caption2.copyWith( + color: isSelected ? ext.accent : ext.textSecondary, + fontWeight: + isSelected ? FontWeight.w600 : FontWeight.w400, + ), + ), + ), + ); + }).toList(), + ), ], ), ); diff --git a/ohos/entry/src/main/ets/formability/pages/CheckinFormPage.ets b/ohos/entry/src/main/ets/formability/pages/CheckinFormPage.ets index 1c7bbef0..e0f58165 100644 --- a/ohos/entry/src/main/ets/formability/pages/CheckinFormPage.ets +++ b/ohos/entry/src/main/ets/formability/pages/CheckinFormPage.ets @@ -1,10 +1,10 @@ /** * @file CheckinFormPage.ets * @created 2025-01-01 - * @updated 2026-05-23 + * @updated 2026-06-04 * @name 签到卡片组件 - * @desc 签到小组件UI,展示连续签到天数和签到状态 - * @lastUpdate 将 @State 替换为 @LocalStorageProp,接收 FormAbility 通过 LocalStorage 传递的数据;@Entry添加storage参数 + * @desc 签到小组件UI,展示连续签到天数和签到状态(含长按操作菜单) + * @lastUpdate: 添加长按上下文菜单支持(签到、打开APP) */ const checkinStorage: LocalStorage = new LocalStorage(); @@ -15,6 +15,56 @@ struct CheckinFormPage { @LocalStorageProp('status') status: string = '📝 点击签到' @LocalStorageProp('isDark') isDark: string = 'false' + // ============================================================ + // 操作菜单项 + // ============================================================ + + @Builder + MenuBuilder() { + MenuItem({ content: '✅ 立即签到', startIcon: $r('app.media.ic_check') }) + .onClick(() => { + this._handleAction('checkin'); + }) + MenuItem({ content: '🔄 刷新', startIcon: $r('app.media.ic_refresh') }) + .onClick(() => { + this._handleAction('refresh'); + }) + MenuItem({ content: '📱 打开APP', startIcon: $r('app.media.ic_open_app') }) + .onClick(() => { + this._handleAction('openApp'); + }) + } + + // ============================================================ + // 操作处理 + // ============================================================ + + private _handleAction(action: string): void { + switch (action) { + case 'checkin': + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { uri: '/signin?action=checkin' }, + }); + break; + case 'refresh': + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { uri: '/home?widget_refresh=Checkin' }, + }); + break; + case 'openApp': + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { uri: '/signin' }, + }); + break; + } + } + build() { Column() { Text(this.days) @@ -41,5 +91,9 @@ struct CheckinFormPage { }, }); }) + .bindContextMenu(this.MenuBuilder, { placement: Placement.BottomLeft, showInSubWindow: false }) + .onLongPress(() => { + vibrator.vibrate({ duration: 30, intensity: 'light' }); + }) } } diff --git a/ohos/entry/src/main/ets/formability/pages/DailyCardFormPage.ets b/ohos/entry/src/main/ets/formability/pages/DailyCardFormPage.ets index 6fb4a141..40ec2175 100644 --- a/ohos/entry/src/main/ets/formability/pages/DailyCardFormPage.ets +++ b/ohos/entry/src/main/ets/formability/pages/DailyCardFormPage.ets @@ -1,10 +1,10 @@ /** * @file DailyCardFormPage.ets * @created 2025-01-01 - * @updated 2026-05-23 + * @updated 2026-06-04 * @name 每日卡片组件 - * @desc 每日一句小组件UI,展示句子、作者和日期 - * @lastUpdate 将 @State 替换为 @LocalStorageProp,接收 FormAbility 通过 LocalStorage 传递的数据;@Entry添加storage参数 + * @desc 每日一句小组件UI,展示句子、作者和日期(含长按操作菜单) + * @lastUpdate: 添加长按上下文菜单支持(保存、分享、打开APP) */ const dailyCardStorage: LocalStorage = new LocalStorage(); @@ -16,6 +16,67 @@ struct DailyCardFormPage { @LocalStorageProp('date') date: string = '' @LocalStorageProp('isDark') isDark: string = 'false' + // ============================================================ + // 操作菜单项 + // ============================================================ + + @Builder + MenuBuilder() { + MenuItem({ content: '💾 保存图片', startIcon: $r('app.media.ic_save') }) + .onClick(() => { + this._handleAction('save'); + }) + MenuItem({ content: '📤 分享', startIcon: $r('app.media.ic_share') }) + .onClick(() => { + this._handleAction('share'); + }) + MenuItem({ content: '🔄 刷新', startIcon: $r('app.media.ic_refresh') }) + .onClick(() => { + this._handleAction('refresh'); + }) + MenuItem({ content: '📱 打开APP', startIcon: $r('app.media.ic_open_app') }) + .onClick(() => { + this._handleAction('openApp'); + }) + } + + // ============================================================ + // 操作处理 + // ============================================================ + + private _handleAction(action: string): void { + switch (action) { + case 'save': + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { uri: '/home?action=save_card' }, + }); + break; + case 'share': + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { uri: '/home?action=share_card' }, + }); + break; + case 'refresh': + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { uri: '/home?widget_refresh=DailyCard' }, + }); + break; + case 'openApp': + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { uri: '/home' }, + }); + break; + } + } + build() { Column() { if (this.date.length > 0) { @@ -39,7 +100,7 @@ struct DailyCardFormPage { .width('100%') .padding({ left: 16, right: 16 }) - Text(this.author) + Text(`— ${this.author}`) .fontSize(11) .fontColor(this.isDark === 'true' ? '#AAAAAA' : '#888888') .width('100%') @@ -61,5 +122,9 @@ struct DailyCardFormPage { }, }); }) + .bindContextMenu(this.MenuBuilder, { placement: Placement.BottomLeft, showInSubWindow: false }) + .onLongPress(() => { + vibrator.vibrate({ duration: 30, intensity: 'light' }); + }) } } diff --git a/ohos/entry/src/main/ets/formability/pages/DailySentenceFormPage.ets b/ohos/entry/src/main/ets/formability/pages/DailySentenceFormPage.ets index a27a1327..f9497586 100644 --- a/ohos/entry/src/main/ets/formability/pages/DailySentenceFormPage.ets +++ b/ohos/entry/src/main/ets/formability/pages/DailySentenceFormPage.ets @@ -1,10 +1,10 @@ /** * @File: DailySentenceFormPage.ets * @Create: 2026-05-19 - * @Update: 2026-05-23 + * @Update: 2026-06-04 * @Name: 每日一句卡片页面 - * @Desc: 鸿蒙桌面卡片-每日一句展示 - * @LastUpdate: 修复数据绑定,使用LocalStorage接收FormAbility传递的数据;@Entry添加storage参数 + * @Desc: 鸿蒙桌面卡片-每日一句展示(含长按操作菜单) + * @LastUpdate: 添加长按上下文菜单支持(刷新、复制、打开APP) */ const dailySentenceStorage: LocalStorage = new LocalStorage(); @@ -15,6 +15,56 @@ struct DailySentenceFormPage { @LocalStorageProp('author') author: string = ''; @LocalStorageProp('isDark') isDark: string = 'false'; + // ============================================================ + // 操作菜单项 + // ============================================================ + + @Builder + MenuBuilder() { + MenuItem({ content: '🔄 刷新', startIcon: $r('app.media.ic_refresh') }) + .onClick(() => { + this._handleAction('refresh'); + }) + MenuItem({ content: '📋 复制句子', startIcon: $r('app.media.ic_copy') }) + .onClick(() => { + this._handleAction('copy'); + }) + MenuItem({ content: '📱 打开APP', startIcon: $r('app.media.ic_open_app') }) + .onClick(() => { + this._handleAction('openApp'); + }) + } + + // ============================================================ + // 操作处理 + // ============================================================ + + private _handleAction(action: string): void { + switch (action) { + case 'refresh': + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { uri: '/home?widget_refresh=DailySentence' }, + }); + break; + case 'copy': + pasteboard.setData({ + textData: `${this.sentence}——${this.author}`, + mimeType: 'text/plain', + }); + promptAction.showToast({ message: '已复制到剪贴板' }); + break; + case 'openApp': + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { uri: '/home' }, + }); + break; + } + } + build() { Column() { Text(this.sentence) @@ -28,7 +78,7 @@ struct DailySentenceFormPage { Blank() - Text(this.author) + Text(`— ${this.author}`) .fontSize(11) .fontColor(this.isDark === 'true' ? '#AAAAAA' : '#888888') .width('100%') @@ -48,5 +98,10 @@ struct DailySentenceFormPage { }, }); }) + .bindContextMenu(this.MenuBuilder, { placement: Placement.BottomLeft, showInSubWindow: false }) + .onLongPress(() => { + // 长按时触发震动反馈 + vibrator.vibrate({ duration: 30, intensity: 'light' }); + }) } } diff --git a/ohos/entry/src/main/ets/formability/pages/FortuneFormPage.ets b/ohos/entry/src/main/ets/formability/pages/FortuneFormPage.ets index e9d8fe32..5dd06e30 100644 --- a/ohos/entry/src/main/ets/formability/pages/FortuneFormPage.ets +++ b/ohos/entry/src/main/ets/formability/pages/FortuneFormPage.ets @@ -1,10 +1,10 @@ /** * @file FortuneFormPage.ets * @created 2025-01-01 - * @updated 2026-05-23 + * @updated 2026-06-04 * @name 运势卡片组件 - * @desc 今日运势小组件UI,展示运势关键词和描述 - * @lastUpdate 将 @State 替换为 @LocalStorageProp,接收 FormAbility 通过 LocalStorage 传递的数据;@Entry添加storage参数 + * @desc 今日运势小组件UI,展示运势关键词和描述(含长按操作菜单) + * @lastUpdate: 添加长按上下文菜单支持(刷新、分享、打开APP) */ const fortuneStorage: LocalStorage = new LocalStorage(); @@ -15,6 +15,52 @@ struct FortuneFormPage { @LocalStorageProp('keyword') keyword: string = '✨' @LocalStorageProp('isDark') isDark: string = 'false' + // ============================================================ + // 操作菜单项 + // ============================================================ + + @Builder + MenuBuilder() { + MenuItem({ content: '🔄 换一条', startIcon: $r('app.media.ic_refresh') }) + .onClick(() => { + this._handleAction('refresh'); + }) + MenuItem({ content: '📤 分享运势', startIcon: $r('app.media.ic_share') }) + .onClick(() => { + this._handleAction('share'); + }) + MenuItem({ content: '📱 打开APP', startIcon: $r('app.media.ic_open_app') }) + .onClick(() => { + this._handleAction('openApp'); + }) + } + + private _handleAction(action: string): void { + switch (action) { + case 'refresh': + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { uri: '/home?widget_refresh=Fortune' }, + }); + break; + case 'share': + pasteboard.setData({ + textData: `今日运势:${this.keyword} ${this.text}`, + mimeType: 'text/plain', + }); + promptAction.showToast({ message: '已复制到剪贴板' }); + break; + case 'openApp': + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { uri: '/inspiration' }, + }); + break; + } + } + build() { Column() { Text(this.keyword) @@ -41,5 +87,9 @@ struct FortuneFormPage { }, }); }) + .bindContextMenu(this.MenuBuilder, { placement: Placement.BottomLeft, showInSubWindow: false }) + .onLongPress(() => { + vibrator.vibrate({ duration: 30, intensity: 'light' }); + }) } } diff --git a/ohos/entry/src/main/ets/formability/pages/ReadlaterFormPage.ets b/ohos/entry/src/main/ets/formability/pages/ReadlaterFormPage.ets index cd3bec8c..ba5d9685 100644 --- a/ohos/entry/src/main/ets/formability/pages/ReadlaterFormPage.ets +++ b/ohos/entry/src/main/ets/formability/pages/ReadlaterFormPage.ets @@ -1,10 +1,10 @@ /** * @file ReadlaterFormPage.ets * @created 2025-01-01 - * @updated 2026-05-23 + * @updated 2026-06-04 * @name 稍后阅读卡片组件 - * @desc 稍后阅读小组件UI,展示未读数量和预览文本 - * @lastUpdate 将 @State 替换为 @LocalStorageProp,接收 FormAbility 通过 LocalStorage 传递的数据;@Entry添加storage参数 + * @desc 稍后阅读小组件UI,展示未读数量和预览文本(含长按操作菜单) + * @lastUpdate: 添加长按上下文菜单支持(打开、刷新) */ const readlaterStorage: LocalStorage = new LocalStorage(); @@ -15,6 +15,52 @@ struct ReadlaterFormPage { @LocalStorageProp('previewText') previewText: string = '' @LocalStorageProp('isDark') isDark: string = 'false' + // ============================================================ + // 操作菜单项 + // ============================================================ + + @Builder + MenuBuilder() { + MenuItem({ content: '📖 打开阅读', startIcon: $r('app.media.ic_open') }) + .onClick(() => { + this._handleAction('open'); + }) + MenuItem({ content: '🔄 刷新', startIcon: $r('app.media.ic_refresh') }) + .onClick(() => { + this._handleAction('refresh'); + }) + MenuItem({ content: '📱 打开APP', startIcon: $r('app.media.ic_open_app') }) + .onClick(() => { + this._handleAction('openApp'); + }) + } + + private _handleAction(action: string): void { + switch (action) { + case 'open': + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { uri: '/home?tab=readlater' }, + }); + break; + case 'refresh': + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { uri: '/home?widget_refresh=Readlater' }, + }); + break; + case 'openApp': + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { uri: '/home' }, + }); + break; + } + } + build() { Column() { Text(this.count) @@ -48,5 +94,9 @@ struct ReadlaterFormPage { }, }); }) + .bindContextMenu(this.MenuBuilder, { placement: Placement.BottomLeft, showInSubWindow: false }) + .onLongPress(() => { + vibrator.vibrate({ duration: 30, intensity: 'light' }); + }) } } diff --git a/ohos/entry/src/main/ets/formability/pages/SolarTermFormPage.ets b/ohos/entry/src/main/ets/formability/pages/SolarTermFormPage.ets index 2442fcbf..87e34630 100644 --- a/ohos/entry/src/main/ets/formability/pages/SolarTermFormPage.ets +++ b/ohos/entry/src/main/ets/formability/pages/SolarTermFormPage.ets @@ -1,10 +1,10 @@ /** * @file SolarTermFormPage.ets * @created 2025-01-01 - * @updated 2026-05-23 + * @updated 2026-06-04 * @name 节气卡片组件 - * @desc 节气小组件UI,展示节气名称和诗句 - * @lastUpdate 将 @State 替换为 @LocalStorageProp,接收 FormAbility 通过 LocalStorage 传递的数据;@Entry添加storage参数 + * @desc 节气小组件UI,展示节气名称和诗句(含长按操作菜单) + * @lastUpdate: 添加长按上下文菜单支持(复制、打开APP) */ const solarTermStorage: LocalStorage = new LocalStorage(); @@ -15,6 +15,52 @@ struct SolarTermFormPage { @LocalStorageProp('poem') poem: string = '春到人间草木知' @LocalStorageProp('isDark') isDark: string = 'false' + // ============================================================ + // 操作菜单项 + // ============================================================ + + @Builder + MenuBuilder() { + MenuItem({ content: '📋 复制诗句', startIcon: $r('app.media.ic_copy') }) + .onClick(() => { + this._handleAction('copy'); + }) + MenuItem({ content: '🔄 刷新', startIcon: $r('app.media.ic_refresh') }) + .onClick(() => { + this._handleAction('refresh'); + }) + MenuItem({ content: '📱 打开APP', startIcon: $r('app.media.ic_open_app') }) + .onClick(() => { + this._handleAction('openApp'); + }) + } + + private _handleAction(action: string): void { + switch (action) { + case 'copy': + pasteboard.setData({ + textData: `【${this.name}】${this.poem}`, + mimeType: 'text/plain', + }); + promptAction.showToast({ message: '已复制到剪贴板' }); + break; + case 'refresh': + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { uri: '/home?widget_refresh=SolarTerm' }, + }); + break; + case 'openApp': + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { uri: '/inspiration' }, + }); + break; + } + } + build() { Column() { Text(this.name) @@ -43,5 +89,9 @@ struct SolarTermFormPage { }, }); }) + .bindContextMenu(this.MenuBuilder, { placement: Placement.BottomLeft, showInSubWindow: false }) + .onLongPress(() => { + vibrator.vibrate({ duration: 30, intensity: 'light' }); + }) } }