diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bbc045..77fe44e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,155 +3,219 @@ All notable changes to this project will be documented in this file. -## [0.98.9] - 2026-04-18 +## [0.99.6] - 2026-04-19 -### ✨ 新增 — 菜谱分享本地存储 + 3天自动过期清理 +### 🐛 Bug 修复 — 帮我做决定转盘指针指向分界线 -#### 菜谱分享数据推送(Flutter → PHP) -- 📤 **RecipeShareService**:新增菜谱分享数据推送服务,将菜谱数据推送到 `recipe_share.php` 本地存储 -- 🔄 **先推送后生成**:二维码海报生成前先推送数据到服务器,推送失败仍显示海报(降级策略) -- 📱 **完整数据推送**:推送菜谱标题/简介/封面/分类/食材/营养/评分等完整信息 -- 🎯 **CRUD接口**:支持创建/更新/删除菜谱分享数据 +#### 问题描述 +- **转盘指针总是指向扇区分界线**:旋转结束后指针停在两个扇区的交界线上,而非内容区域中心 +- **根因分析**: + 1. `extraSpins` 始终为整数(5~9),视觉旋转角度为 `extraSpins × 2π`,永远是完整圈数,转盘每次都回到起始位置 + 2. `_onSpinComplete` 中角度计算存在 `* 5` 遗留 bug 和 `+ π/2` 错误偏移 -#### recipe_share.php 重构 -- 💾 **本地JSON存储**:从调用外部API改为本地JSON文件存储(`cache/kitchen/recipe_*.json`) -- ⏰ **3天自动过期**:数据写入时自动设置3天过期时间,读取时自动检查并清理过期文件 -- 🧹 **清理接口**:新增 `?act=cleanup` 接口,手动清理所有过期数据 -- 📋 **数据目录共享**:与 `kitchen.php` 共享 `cache/kitchen/` 目录 +#### 修复方案 +- 📝 **修改 `decision_maker_page.dart`**: + - 采用"预选结果 + 精确角度"标准转盘方案 + - 先随机选中目标扇区,再计算指针指向该扇区中心所需的精确旋转角度 + - 在扇区内添加 ±30% 随机偏移,避免每次都指向正中心,更自然 + - 移除 `_onSpinComplete` 中错误的角度反算逻辑,改用预设结果 -#### 二维码海报组件改进 -- 🎨 **加载提示**:推送数据时显示 CupertinoActivityIndicator 加载动画 -- 📦 **RecipeModel传递**:`showQrPosterSheet` 新增 `recipe` 参数,支持完整菜谱数据传递 -- 🔗 **recipe_action_bar**:新增 `recipe` 字段,详情页传递完整菜谱对象 +#### 技术说明 +- 标准转盘实现应先确定结果再计算动画,而非先动画再反算结果 +- `startDegreeOffset: -90` 使第一个扇区从12点方向开始,计算时无需额外偏移 #### 修改文件 -- `lib/src/services/data/recipe_share_service.dart` — 新增菜谱分享数据推送服务 -- `lib/src/widgets/recipe_detail/interaction/recipe_qr_poster.dart` — 改为先推送数据再生成二维码 -- `lib/src/widgets/recipe_detail/interaction/recipe_action_bar.dart` — 新增 recipe 参数 -- `lib/src/pages/home/recipe_detail_page.dart` — 传递 recipe 到操作栏 -- `docs/api/recipe_share.php` — 重构为本地存储,新增3天过期清理功能 +- `lib/src/pages/tools/cooking/decision_maker_page.dart` — 修复转盘旋转与结果计算逻辑 -## [0.98.8] - 2026-04-18 -### ✨ 新增 — 二维码分享URL生成 + PHP分享页面 +## [0.99.5] - 2026-04-19 -#### 二维码URL生成 -- 🔗 **QR URL重构**:二维码URL从硬编码改为通过 `ApiConfig.baseUrl` 动态生成 -- 📱 **URL格式**:`https://eat.wktyl.com/api/recipe_share.php?code=CP00001` 或 `?id=123` -- 🆔 **双重参数**:支持 `code`(菜谱编码)和 `recipeId`(数字ID)两种查询方式 -- 🔄 **降级策略**:优先使用 code,无 code 时使用 recipeId,均无则生成 app scheme URL +### 🐛 Bug 修复 — 发现页面 Dismissible 组件错误 -#### PHP分享页面(recipe_share.php) -- 🍳 **美观分享页**:iOS风格设计,深色模式自适应,毛玻璃效果,流畅动画 -- 📊 **访问统计**:自动记录每次扫码访问,按菜谱统计浏览量 -- 📝 **访问日志**:记录IP、UA、来源等,保留最近200条 -- 🏷️ **OG标签**:支持微信/社交媒体分享预览(标题/描述/封面图) -- 🔌 **API代理**:通过调用 `api.php` 获取数据,不直接查询数据库 -- 📈 **管理接口**:`?act=stats` 查看统计、`?act=log` 查看日志、`?act=api` 返回JSON +#### 问题描述 +- **发现页面热门列表 Dismissible 报错**:右滑显示操作面板后,Dismissible widget 未从树中移除,导致红色错误提示 +- **错误信息**:"A dismissed Dismissible widget is still part of the tree" + +#### 修复方案 +- 📝 **修改 `discover_sections_widget.dart`**: + - 将右滑操作的 `confirmDismiss` 返回值从 `true` 改为 `false` + - 移除 `onDismissed` 回调中的显示操作面板逻辑 + - 改为在 `confirmDismiss` 中直接调用 `_showQuickActions()` 并返回 `false` + - 这样 Dismissible 不会真正被 dismiss,避免 widget 树不一致的错误 + +#### 技术说明 +- Flutter 的 Dismissible 组件要求:当 `confirmDismiss` 返回 `true` 时,必须在 `onDismissed` 中立即移除该 widget +- 如果只是想展示操作面板而不删除 item,应在 `confirmDismiss` 中处理并返回 `false` #### 修改文件 -- `lib/src/widgets/recipe_detail/interaction/recipe_qr_poster.dart` — QR URL生成逻辑重构,新增 recipeId 参数 -- `lib/src/widgets/recipe_detail/interaction/recipe_action_bar.dart` — 新增 recipeId 传递 -- `lib/src/pages/home/recipe_detail_page.dart` — 传入 recipe.id 到二维码组件 -- `docs/api/recipe_share.php` — 新增PHP分享页面 +- `lib/src/pages/discover/components/discover_sections_widget.dart` — 修复 Dismissible 逻辑 -## [0.98.7] - 2026-04-18 +--- -### ✨ 新增 — 口味偏好持久化 + 菜品详情页偏好标注 +## [0.99.4] - 2026-04-19 -#### 口味偏好持久化 -- 💾 **TastePreferenceService**:新增口味偏好持久化服务,所有偏好值写入 SharedPreferences -- 🥗 **饮食类型/辣度/口味/烹饪水平/人数/健康目标**:6项偏好值全部持久化,应用重启后自动恢复 -- 🔄 **PreferencePage 接入**:偏好设置页改用 `TastePreferenceService` 读写,移除本地 State 临时变量 -- 🧹 **枚举统一**:DietType/SpiceLevel/CookingLevel/ServingSize/HealthGoal 统一定义在 `TastePreferenceService` 中 +### ✨ 新功能 — 分享记录管理页面 -#### 菜品详情页偏好标注 -- 💚 **我的偏好卡片**:在菜品详情页过敏原警告下方新增「我的偏好」标注卡片 -- 🌶️ **辣度匹配**:对比用户辣度偏好与菜品口味标签,标注「很匹配/一般/不太匹配」 -- 👅 **口味匹配**:对比用户甜/咸/酸偏好与菜品口味,计算匹配度 -- 👨‍🍳 **难度匹配**:对比用户烹饪水平与菜品难度,标注匹配度 -- 🥗 **饮食类型匹配**:检测菜品食材是否包含用户饮食禁忌(素食/纯素/低碳水/生酮/清真等) -- ⚠️ **过敏原标注**:只标注用户选择的过敏原,显示「安全」或「含过敏原」 +#### 分享记录页面(🔗 分享管理) +- 📄 **新增 `share_record_model.dart`**:分享记录数据模型,支持文本/链接/二维码/邮件四种分享类型 +- 🎮 **新增 `share_record_controller.dart`**:分享记录控制器,SharedPreferences 持久化,支持搜索/筛选/统计 +- 📱 **新增 `share_records_page.dart`**:iOS 26 Liquid Glass 风格分享记录页面,支持搜索、类型筛选、左滑删除、清空、重新分享、查看菜谱详情 +- 🔗 **修改 `app_routes.dart`**:新增 `/share-records` 路由常量和页面映射 +- 🏠 **修改 `profile_home.dart`**:分享记录入口图标改为 `CupertinoIcons.share`,路由指向新的分享记录页面 +- 📤 **修改 `recipe_detail_controller.dart`**:分享菜谱时自动记录到分享历史 #### 修改文件 -- `lib/src/services/user/taste_preference_service.dart` — 新增口味偏好持久化服务 -- `lib/src/pages/profile/settings/preference_page.dart` — 接入 TastePreferenceService -- `lib/src/widgets/recipe_detail/info/recipe_taste_preference.dart` — 新增菜品偏好标注组件 -- `lib/src/pages/home/recipe_detail_page.dart` — 集成偏好标注组件 -- `lib/src/app_binding.dart` — 注册 TastePreferenceService +- `lib/src/models/data/share_record_model.dart` — 新建:分享记录模型 +- `lib/src/controllers/data/share_record_controller.dart` — 新建:分享记录控制器 +- `lib/src/pages/profile/social/share_records_page.dart` — 新建:分享记录页面 +- `lib/src/config/app_routes.dart` — 新增路由 + 页面映射 + PageInfo +- `lib/src/pages/profile/profile_home.dart` — 分享记录入口路由修正 +- `lib/src/controllers/recipe/recipe_detail_controller.dart` — 分享时自动记录 -## [0.98.6] - 2026-04-18 -### ✨ 新增与改进 +## [0.99.3] - 2026-04-19 -#### 软件信息页面 -- 🖼️ **头部卡片图标**:将 emoji 替换为应用图标 `icon_128x128.png`,图标与文本左右居中对齐 -- 📦 **assets/icons 注册**:在 `pubspec.yaml` 中添加 `assets/icons/` 资源目录 +### ✨ 新功能 — 评分记录管理页面 -#### 参考文献页面 -- 📚 **新增参考文献**:从 8 条扩充至 18 条,覆盖 BMI、过敏原、特殊人群、烹饪安全、慢病防控、GI 数据库等 -- 🏷️ **分类标签优化**:分类名增加 emoji 前缀(🥗 营养健康、⚠️ 过敏原、⚖️ 体重管理等) - -#### 偏好设置页面(重写) -- 🥗 **饮食类型**:普通/素食/纯素/低碳水/生酮/地中海/清真 -- 🌶️ **辣度偏好**:不吃辣/微辣/中辣/重辣/变态辣 -- 👅 **口味偏好**:甜度/咸度/酸度滑块调节 -- 👨‍🍳 **烹饪水平**:厨房小白/入门选手/熟练厨师/厨艺大师 -- 🍚 **每餐人数**:1-2人/3-4人/5人+ -- 🎯 **健康目标**:减脂/增肌/控糖/心血管/肠胃/免疫/骨骼(多选) -- ✅ **偏好摘要卡片**:底部汇总所有偏好设置 -- 🔄 **重置功能**:一键重置所有偏好 - -#### 路由修复 -- 🐛 **修复 /about 路由未注册**:在 `PageRegistry.registerAll` 中补充 about/privacyPolicy/guide 路由 -- 🐛 **修复 app_routes.dart 乱码**:`'分类浏览'` 替换损坏的 Unicode 字符 - -## [0.98.5] - 2026-04-18 - -### ✨ 新增 — 软件信息页面 + 了解我们页面 - -#### 软件信息页面 -- 📱 **新增软件信息页面**:展示应用版本、技术栈、构建信息、后端服务、设备信息、更新日志 -- 🎨 **毛玻璃头部卡片**:渐变背景 + BackdropFilter 毛玻璃效果,展示应用名称和版本 -- ⚙️ **技术栈展示**:2×2 网格布局展示 Dart / GetX / Hive / Dio 技术栈 -- 🔨 **构建信息**:版本号、内部版本号、打包时间、Build SDK,支持点击复制 -- 🖥️ **后端服务信息**:展示 PHP / Nginx / API 地址 -- 📱 **设备信息**:操作系统、设备类型、Dart 版本(动态获取)、渲染引擎(三层检测+标注)、屏幕尺寸和像素密度 -- 🎨 **渲染引擎检测**:三层检测机制(API → 引擎 → 平台),使用 `defaultTargetPlatform` + `TargetPlatform.ohos` 精准识别 -- 🐛 **修复鸿蒙端设备信息显示 Unknown**:`PlatformUtils.isHarmonyOS` 改用 `Platform.isOhos` 替代字符串匹配 -- 🐛 **修复鸿蒙端渲染引擎显示默认**:改用 `defaultTargetPlatform` 枚举匹配,鸿蒙端正确显示 `Impeller (平台)` -- 📋 **更新日志**:展示版本更新记录 -- 🔓 **隐藏开发者模式**:连续点击 Flutter 标签 5 次激活开发者模式 -- 📄 **开源框架弹窗**:展示所有依赖的开源协议 -- 🔗 **关于页面入口修改**:软件信息从打开 URL 改为跳转 AppInfoPage - -#### 了解我们页面 -- 📖 **了解我们页面**:新增 `LearnUsPage`,展示开发者信息、团队信息、官网链接和ICP备案号 -- 🔗 **关于页面入口**:关于页面"了解我们"入口从打开外部链接改为跳转新页面 -- 🎨 **iOS风格UI**:使用 CupertinoPageScaffold + DesignTokens 统一主题,支持深色模式 -- 📋 **链接确认弹窗**:点击外部链接时弹出 CupertinoAlertDialog 确认,支持复制链接 +#### 评分记录页面(⭐ 评分管理) +- ⭐ **新增 `rating_records_page.dart`**:iOS 26 Liquid Glass 风格的评分记录管理页面 + - **评分列表**:展示用户所有评分记录,包含菜谱封面、名称、分类、星级、评分等级标签 + - **搜索功能**:实时搜索评分记录(按菜谱名称/分类过滤) + - **评分筛选**:按评分等级(5分/4分/3分/2分/1分)快速筛选 + - **排序功能**:支持最新/最早/高分优先/低分优先四种排序 + - **批量管理**:编辑模式下支持全选/批量删除评分记录 + - **统计信息**:顶部展示总记录数、平均分、各评分等级数量 + - **导出功能**:支持 JSON/CSV 导出评分记录 + - **空状态**:无评分时展示引导用户去发现菜谱 + - **长按操作**:长按评分卡片进入编辑模式 + - **点击跳转**:点击评分卡片跳转到对应菜谱详情页 +- 📊 **新增 `RatingRecordModel`**:评分记录数据模型,支持 Hive 持久化 + - 包含:recipeId、recipeTitle、coverImage、categoryName、score、ratedAt、type + - 提供:scoreLabel(完美/推荐/一般/较差/不推荐)、scoreEmoji、displayDate 等便捷属性 + - 支持:fromJson/toJson/copyWith 序列化和复制 +- 🎮 **新增 `RatingRecordsController`**:评分记录控制器 + - 与 ActionController 的 ratedItems 同步,自动合并已有评分数据 + - 支持 Hive 持久化存储,应用重启后数据不丢失 + - 支持增删改查、搜索、筛选、排序、批量操作、统计、导出 +- 🗄️ **HiveService 扩展**:新增 ratingRecordBox 和 CRUD 方法 +- 🔗 **路由注册**:新增 `/rating-records` 路由 +- 🔄 **profile_home.dart**:评分记录入口从 favorites 路由改为 ratingRecords 路由 #### 修改文件 -- `lib/src/pages/profile/app_info_page.dart` — 新增软件信息页面 -- `lib/src/pages/profile/learn_us_page.dart` — 新增了解我们页面 -- `lib/src/pages/profile/about_page.dart` — 软件信息/了解我们入口改为跳转新页面 +- `lib/src/models/data/rating_record_model.dart` — 新建:评分记录数据模型 +- `lib/src/controllers/data/rating_records_controller.dart` — 新建:评分记录控制器 +- `lib/src/pages/profile/rating_records_page.dart` — 新建:评分记录管理页面 +- `lib/src/services/data/hive_service.dart` — 扩展:新增评分记录 Box 和 CRUD 方法 +- `lib/src/config/app_routes.dart` — 新增路由常量和页面注册 +- `lib/src/app_binding.dart` — 新增 RatingRecordsController 全局注册 +- `lib/src/pages/profile/profile_home.dart` — 评分记录入口路由修正 -## [0.98.4] - 2026-04-18 +## [0.99.2] - 2026-04-19 -### 🐛 修复 — 小妈菜园交互优化 + 商店布局修复 +### ✨ 新功能 — 帮我做决定转盘工具 + 工具中心内容扩展 -#### 变更 -- 🔇 **消息限流机制**:添加 Toast 限流(5秒内最多2次,10秒内最多3次),重复消息自动过滤,避免气泡弹出过于频繁 -- 🔄 **消息系统统一**:所有 `Get.snackbar` 替换为 `ToastService`,统一使用项目消息服务 -- 📐 **商店布局重构**:种子商店卡片从单行布局改为上下分行(名称+购买按钮同行,信息标签换行),使用 `Wrap` 防止溢出 -- 📤 **分享功能确认**:`FarmShareUtil` 已支持图片+文本分享(`Share.shareXFiles` + `text`),无需修改 +#### 帮我做决定(🎯 转盘决策工具) +- 🎯 **新增 `decision_maker_page.dart`**:转盘随机决策工具页面 + - 支持**模板模式**:内置"今天吃什么"、"喝什么"、"做还是不做"、"去哪里玩"、"选哪个"5个预设模板 + - 支持**自定义模式**:用户自由添加/删除选项(最多12个),自动分配颜色和emoji + - **旋转动画**:使用 fl_chart PieChart 绘制扇形 + RotationTransition 实现流畅旋转动画(Curves.decelerate 减速曲线) + - **结果弹出动画**:ScaleTransition + FadeTransition 弹性弹出结果卡片 + - **触感反馈**:结果确定时 HapticFeedback.mediumImpact() + - **结果复制**:一键复制决定结果到剪贴板 + - **决定历史**:记录最近10条决定结果,支持清空 + - **指针组件**:CustomPainter 绘制顶部三角形指针,带阴影和白色边框 +- 🛤️ **路由注册**:新增 `/tools/decision-maker` 路由 +- 📋 **工具注册**:在 ToolRegistry 中注册,分类为 cooking,瀑布流展示 priority:2,badge: 'NEW' + +#### 工具中心页面内容扩展 +- ⭐ **精选推荐区域**:横向滚动卡片展示推荐工具(帮我做决定、小妈菜园、烹饪计时器、点餐助手、每周菜单规划) +- 🕐 **最近使用区域**:按使用频率排序展示最近使用的4个工具,显示使用次数 +- 🎬 **入场动画增强**:精选推荐和最近使用区域增加 SlideTransition + FadeTransition 交错动画 +- 🔍 **搜索状态优化**:搜索时隐藏推荐/最近区域,仅展示搜索结果 #### 修改文件 -- `lib/src/controllers/farm/farm_game_controller.dart` — 添加消息限流、替换 Get.snackbar -- `lib/src/controllers/farm/farm_shop_controller.dart` — 替换 Get.snackbar 为 ToastService -- `lib/src/pages/tools/farm/farm_shop_page.dart` — 重构卡片布局,信息标签和购买按钮分行 +- `lib/src/pages/tools/cooking/decision_maker_page.dart` — 新建:帮我做决定页面 +- `lib/src/pages/tools/tools_center_page.dart` — 扩展:新增精选推荐/最近使用区域 +- `lib/src/models/tool_item_model.dart` — 新增 decision_maker 工具项 +- `lib/src/config/app_routes.dart` — 新增路由常量和页面注册 +## [0.99.1] - 2026-04-19 -> 📌 已移除较早版本记录(0.98.3及之前),功能已归档至软件特性清单。 +### 🔧 修复 — 版本号同步 + Android 应用名修正 + R8 Play Core 警告 + +#### 版本号同步 +- 📌 **pubspec.yaml**:版本号从 0.96.0+95 同步更新至 0.99.1+100,与 CHANGELOG 一致 +- 📱 **profile_home.dart**:底部版本号显示从 v0.88.5 更新至 v0.99.1 +- ⚙️ **profile_settings.dart**:底部版本号显示从 v0.88.5 更新至 v0.99.1 + +#### Android 应用名修正 +- 🏷️ **AndroidManifest.xml**:`android:label` 从 "mom_kitchen" 改为 "小妈厨房",与应用中文名一致 + +#### R8 Play Core 缺失类警告修复 +- 🛡️ **proguard-rules.pro**:添加 Play Core 分包安装相关类的 `-dontwarn` 规则 +- 🔧 **原因**:Flutter 引擎引用了 `com.google.android.play.core.splitinstall.*` 等类,但项目未使用分包安装功能,这些类在运行时不会被调用 +- ✅ **效果**:抑制 R8 编译时的 12 条 Missing class 警告 + +#### 修改文件 +- `pubspec.yaml` — 版本号同步 0.96.0 → 0.99.1 +- `lib/src/pages/profile/profile_home.dart` — 版本号显示更新 +- `lib/src/pages/profile/profile_settings.dart` — 版本号显示更新 +- `android/app/src/main/AndroidManifest.xml` — 应用名改为中文 +- `android/app/proguard-rules.pro` — 添加 Play Core dontwarn 规则 + + +## [0.99.0] - 2026-04-19 + +### 🐛 修复 — Android Release 包缓存失效、图片加载不出、浏览记录丢失 + +#### 根因分析 +Release 包开启了 R8 代码混淆(`isMinifyEnabled=true`),但缺少 Proguard 规则文件, +导致 Hive、SharedPreferences、CachedNetworkImage 等依赖的反射类被混淆或移除, +序列化/反序列化失败 → 所有本地缓存数据丢失 → 图片无法加载 → 浏览记录为空。 + +#### 修复内容 +- 🛡️ **新增 `proguard-rules.pro`**:保护 Hive TypeAdapter、SharedPreferences、CachedNetworkImage、Dio 等反射类不被 R8 混淆 +- ⚙️ **`build.gradle.kts`**:release 构建启用 `isMinifyEnabled=true` + `isShrinkResources=true` 并引用 proguard 规则 +- 🔒 **`AndroidManifest.xml`**:添加 `android:allowBackup=false` 防止 Auto Backup 覆盖缓存;添加 `android:networkSecurityConfig` 确保 HTTPS 请求正常 +- 🌐 **新增 `network_security_config.xml`**:配置网络安全策略,允许访问 `eat.wktyl.com` 的 HTTPS 资源 +- 🧹 **修复 `RecipeImageCache.clearCache()`**:原来传空字符串 `evictFromCache('')` 无效,改为使用 `DefaultCacheManager().emptyCache()` 正确清理 + +#### 修改文件 +- `android/app/proguard-rules.pro` — 新建:Proguard 混淆规则 +- `android/app/build.gradle.kts` — 启用代码压缩和混淆,引用 proguard 规则 +- `android/app/src/main/AndroidManifest.xml` — 添加 allowBackup/networkSecurityConfig +- `android/app/src/main/res/xml/network_security_config.xml` — 新建:网络安全配置 +- `lib/src/widgets/recipe/recipe_image.dart` — 修复 RecipeImageCache.clearCache() 无效实现 + +## [0.98.10] - 2026-04-18 + +### 🔧 重构 — 删除接口 RESTful 规范化 + +#### 删除接口重构(GET → DELETE) +- 🗑️ **RESTful 规范**:删除接口从 `GET ?act=delete&id=xxx` 改为 `DELETE ?id=xxx` +- 🔒 **CORS 配置**:后端新增 DELETE 方法支持 (`Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS`) +- 🔄 **前端适配**:`RecipeShareService.deleteRecipeShare()` 从 `_dio.get()` 改为 `_dio.delete()` +- 📝 **参数简化**:删除操作不再需要 `act=delete` 参数,直接使用 HTTP 方法语义 +- 📚 **文档同步**:API_DOC.md 新增删除接口详细说明,包含请求/响应示例 + +#### 技术改进 +- ✅ **符合 RESTful 原则**:使用正确的 HTTP 方法表达语义(DELETE 表示删除) +- ✅ **避免 GET 副作用**:GET 请求不应有副作用,删除操作必须使用非常规方法 +- ✅ **提升安全性**:某些防火墙/代理会记录但不执行 GET 请求的副作用操作 +- ✅ **向后兼容**:后端同时支持新旧两种方式(优先检测 DELETE 方法) + +#### 修改文件 +- `docs/api/recipe_share.php` — 添加 DELETE 方法支持、更新 CORS 配置、修改文件头注释 +- `lib/src/services/data/recipe_share_service.dart` — 删除方法改为 RESTful DELETE +- `docs/api/doc/API_DOC.md` — 新增删除接口完整文档(参数、返回值、示例) + + +> 📌 已移除较早版本记录(0.98.9及之前),功能已归档至软件特性清单。 +> - 0.98.9: 菜谱分享本地存储 + 3天自动过期清理 +> - 0.98.8: 二维码分享URL生成 + PHP分享页面 +> - 0.98.7: 口味偏好持久化 + 菜品详情页偏好标注 +> - 0.98.6: 软件信息页面图标/参考文献扩充/偏好设置重写/路由修复 +> - 0.98.5: 软件信息页面 + 了解我们页面 +> - 0.98.4: 小妈菜园交互优化 + 商店布局修复 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ef23fb3..2065b60 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } android { - namespace = "com.example.mom_kitchen" + namespace = "cute.major.kitchen" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -21,7 +21,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.mom_kitchen" + applicationId = "cute.major.kitchen" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion @@ -32,7 +32,13 @@ android { buildTypes { release { - // TODO: Add your own signing config for the release build. + // 启用代码压缩和混淆(修复 release 包缓存失效问题) + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) // Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.getByName("debug") } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..8ed3768 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,103 @@ +# ======================================== +# Mom's Kitchen - Android Proguard Rules +# 修复 release 包缓存失效、图片加载不出的问题 +# 创建: 2026-04-19 +# ======================================== + +# ─── Flutter 框架 ─── +-keep class io.flutter.app.** { *; } +-keep class io.flutter.plugin.** { *; } +-keep class io.flutter.util.** { *; } +-keep class io.flutter.view.** { *; } +-keep class io.flutter.** { *; } +-keep class io.flutter.plugins.** { *; } + +# ─── Hive CE 本地数据库 ─── +# Hive 使用反射进行序列化/反序列化,TypeAdapter 必须保留 +-keep class * extends com.hive_ce.** { *; } +-keep class * implements com.hive_ce.** { *; } +-keep class hive.** { *; } +-keepclassmembers class * { + @hive.TypeAdapter ; +} +# 保留所有 Hive TypeAdapter 子类(Dart 侧通过 typeId 映射) +-keep class * extends java.lang.Object { *; } + +# ─── SharedPreferences ─── +# SharedPreferences 的 Flutter 插件使用方法通道 +-keep class io.flutter.plugins.sharedpreferences.** { *; } +-keep class **.SharedPreferencesPlugin { *; } + +# ─── CachedNetworkImage / FlutterCacheManager ─── +# 图片缓存依赖文件系统和 HTTP 客户端 +-keep class com.baseflow.cachednetworkimage.** { *; } +-keep class io.flutter.plugins.flutter_cache_manager.** { *; } + +# ─── Dio / HTTP 相关 ─── +# Dio 使用反射解析响应数据 +-keep class com.squareup.okhttp.** { *; } +-dontwarn okio.** +-dontwarn javax.annotation.** + +# ─── Path Provider ─── +# 获取应用目录路径 +-keep class io.flutter.plugins.pathprovider.** { *; } + +# ─── Connectivity Plus ─── +-keep class io.flutter.plugins.connectivityplus.** { *; } + +# ─── Share Plus ─── +-keep class io.flutter.plugins.shareplus.** { *; } + +# ─── JSON 序列化 ─── +# Dart 的 jsonDecode/jsonEncode 在原生层依赖这些类 +-keep class * implements java.io.Serializable { *; } +-keepclassmembers class * implements java.io.Serializable { + static final long serialVersionUID; + private static final java.io.ObjectStreamField[] serialPersistentFields; + !static !transient ; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); + java.lang.Object writeReplace(); + java.lang.Object readResolve(); +} + +# ─── 通用规则 ─── +# 不混淆包含 native 方法的类 +-keepclasseswithmembernames class * { + native ; +} + +# 保留枚举类 +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} + +# 保留 Parcelable 实现 +-keepclassmembers class * implements android.os.Parcelable { + public static final android.os.Parcelable$Creator CREATOR; +} + +# ─── Play Core (分包安装) ─── +# Flutter 引擎引用了 Play Core 类,但项目未使用分包安装功能 +# 这些类在运行时不会被调用,仅需抑制 R8 警告 +-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication +-dontwarn com.google.android.play.core.splitinstall.SplitInstallException +-dontwarn com.google.android.play.core.splitinstall.SplitInstallManager +-dontwarn com.google.android.play.core.splitinstall.SplitInstallManagerFactory +-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest +-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest$Builder +-dontwarn com.google.android.play.core.splitinstall.SplitInstallSessionState +-dontwarn com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener +-dontwarn com.google.android.play.core.tasks.OnFailureListener +-dontwarn com.google.android.play.core.tasks.OnSuccessListener +-dontwarn com.google.android.play.core.tasks.Task +-dontwarn com.google.android.play.core.** + +# 不警告缺失的引用 +-dontwarn javax.annotation.** +-dontwarn kotlin.Unit +-dontwarn retrofit2.** +-dontwarn okhttp3.** +-dontwarn okio.** diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 32bb818..d2d11cc 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,9 +12,12 @@ + android:icon="@mipmap/ic_launcher" + android:allowBackup="false" + android:fullBackupContent="false" + android:networkSecurityConfig="@xml/network_security_config"> + + + + + + + + + + + + eat.wktyl.com + + + + + diff --git a/docs/api/doc/API_DOC.md b/docs/api/doc/API_DOC.md index 23389d0..4f79256 100644 --- a/docs/api/doc/API_DOC.md +++ b/docs/api/doc/API_DOC.md @@ -2267,6 +2267,42 @@ final response = await http.get( --- +### 🗑️ 删除分享数据 + +``` +DELETE recipe_share.php?id=32892 +``` + +**功能**: 删除指定的菜谱分享数据(RESTful 规范) + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | int | ✅ | 菜谱ID | + +**返回示例**: +```json +{ + "code": 200, + "message": "菜谱分享数据删除成功", + "data": { + "deleted_id": 32892, + "deleted_file": "/cache/kitchen/recipe_32892.json" + } +} +``` + +**客户端实现**: +```dart +final response = await dio.delete( + '$baseUrl/recipe_share.php', + queryParameters: {'id': recipeId}, +); +``` + +**注意**: 此接口遵循 RESTful 规范,使用 DELETE 方法而非 GET 方法 + +--- + ### 📖 接口说明 ``` diff --git a/docs/api/recipe_share.php b/docs/api/recipe_share.php index 0cfcf67..88b57a4 100644 --- a/docs/api/recipe_share.php +++ b/docs/api/recipe_share.php @@ -11,7 +11,7 @@ * GET ?act=api&code=CP00001 获取菜谱JSON数据 * POST ?act=create 创建菜谱分享数据 * POST ?act=update 更新菜谱分享数据 - * GET ?act=delete&id=xxx 删除菜谱分享数据 + * DELETE ?id=xxx 删除菜谱分享数据 (RESTful) * GET ?act=list 菜谱分享列表 * GET ?act=stats 查看分享统计 * GET ?act=log 查看访问日志 @@ -28,7 +28,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { header('Content-Type: application/json; charset=utf-8'); header('Access-Control-Allow-Origin: *'); - header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); + header('Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type'); header('Access-Control-Max-Age: 86400'); http_response_code(204); @@ -65,6 +65,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $act = strtolower(trim($_GET['act'] ?? 'share')); +// ─── RESTful DELETE 支持 ─── +if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { + $act = 'delete'; + if (!isset($_GET['id']) && isset($_REQUEST['id'])) { + $_GET['id'] = $_REQUEST['id']; + } +} + // ─── 数据目录配置(与kitchen.php共享) ─── $dataDir = dirname(__FILE__) . '/cache/kitchen/'; if (!is_dir($dataDir)) { @@ -134,7 +142,7 @@ switch ($act) { 'api' => '?act=api&code=CP00001 (JSON数据)', 'create' => 'POST ?act=create {recipe JSON}', 'update' => 'POST ?act=update {recipe JSON}', - 'delete' => 'GET ?act=delete&id=xxx', + 'delete' => 'DELETE ?id=xxx (RESTful)', 'list' => 'GET ?act=list', 'cleanup' => 'GET ?act=cleanup (清理过期数据)', 'stats' => '?act=stats (分享统计)', diff --git a/lib/src/app_binding.dart b/lib/src/app_binding.dart index 4c640d3..b9377e6 100644 --- a/lib/src/app_binding.dart +++ b/lib/src/app_binding.dart @@ -19,6 +19,7 @@ import 'package:mom_kitchen/src/controllers/recipe/search_controller.dart'; import 'package:mom_kitchen/src/controllers/data/shopping_list_controller.dart'; import 'package:mom_kitchen/src/controllers/data/cooking_note_controller.dart'; import 'package:mom_kitchen/src/controllers/data/browse_history_controller.dart'; +import 'package:mom_kitchen/src/controllers/data/rating_records_controller.dart'; import 'package:mom_kitchen/src/controllers/tools/tools_controller.dart'; import 'package:mom_kitchen/src/controllers/tools/what_to_eat_controller.dart'; import 'package:mom_kitchen/src/controllers/data/weekly_menu_controller.dart'; @@ -84,6 +85,7 @@ class AppBinding extends Bindings { Get.lazyPut(() => WeeklyMenuController(), fenix: true); Get.lazyPut(() => BedtimeReminderController(), fenix: true); Get.lazyPut(() => MealRecordController(), fenix: true); + Get.lazyPut(() => RatingRecordsController(), fenix: true); } } diff --git a/lib/src/config/app_config.dart b/lib/src/config/app_config.dart index e2b2d38..69f19fa 100644 --- a/lib/src/config/app_config.dart +++ b/lib/src/config/app_config.dart @@ -17,7 +17,7 @@ class AppConfig { static const String baseUrl = 'https://eat.wktyl.com'; // 超时时间(秒) - static const int timeoutSeconds = 10; + static const int timeoutSeconds = 8; // 缓存时间(天) static const int cacheDays = 7; diff --git a/lib/src/config/app_routes.dart b/lib/src/config/app_routes.dart index af49891..a827055 100644 --- a/lib/src/config/app_routes.dart +++ b/lib/src/config/app_routes.dart @@ -52,7 +52,9 @@ import 'package:mom_kitchen/src/pages/profile/references_page.dart'; import 'package:mom_kitchen/src/pages/profile/privacy_policy_page.dart'; import 'package:mom_kitchen/src/pages/profile/guide_page.dart'; import 'package:mom_kitchen/src/pages/profile/learn_us_page.dart'; +import 'package:mom_kitchen/src/pages/profile/rating_records_page.dart'; import 'package:mom_kitchen/src/pages/profile/social/email_history_page.dart'; +import 'package:mom_kitchen/src/pages/profile/social/share_records_page.dart'; import 'package:mom_kitchen/src/pages/tools/cooking/date_calculator_page.dart'; import 'package:mom_kitchen/src/pages/tools/cooking/food_copy_generator_page.dart'; import 'package:mom_kitchen/src/pages/tools/health/safe_period_calculator_page.dart'; @@ -62,6 +64,7 @@ import 'package:mom_kitchen/src/pages/tools/ranking/dish_ranking_page.dart'; import 'package:mom_kitchen/src/pages/tools/ingredient_manage_page.dart'; import 'package:mom_kitchen/src/pages/tools/tool_detail_page.dart'; import 'package:mom_kitchen/src/pages/tools/cooking/order_assistant_page.dart'; +import 'package:mom_kitchen/src/pages/tools/cooking/decision_maker_page.dart'; import 'package:mom_kitchen/src/pages/tools/farm/farm_game_page.dart'; import 'package:mom_kitchen/src/pages/tools/farm/farm_shop_page.dart'; import 'package:mom_kitchen/src/pages/tools/farm/farm_inventory_page.dart'; @@ -123,6 +126,7 @@ class AppRoutes { static const String nutritionRecipeList = '/nutrition-recipe-list'; static const String miniCard = '/mini-card'; static const String emailHistory = '/email-history'; + static const String shareRecords = '/share-records'; static const String hot = '/hot'; static const String dateCalculator = '/tools/date-calculator'; static const String foodCopyGenerator = '/tools/food-copy'; @@ -132,7 +136,9 @@ class AppRoutes { static const String toolsIngredientManage = '/tools/ingredient-manage'; static const String toolDetail = '/tool-detail'; static const String toolsOrderAssistant = '/tools/order-assistant'; + static const String toolsDecisionMaker = '/tools/decision-maker'; static const String dataExport = '/data-export'; + static const String ratingRecords = '/rating-records'; // 农场游戏路由 static const String farmGame = '/farm-game'; @@ -196,6 +202,11 @@ class AppRoutes { page: () => const DataExportPage(), middlewares: [PageStandardsMiddleware()], ), + GetPage( + name: ratingRecords, + page: () => const RatingRecordsPage(), + middlewares: [PageStandardsMiddleware()], + ), // 农场游戏路由 GetPage( name: farmGame, @@ -515,6 +526,11 @@ class AppRoutes { page: () => const EmailHistoryPage(), middlewares: [PageStandardsMiddleware()], ), + GetPage( + name: shareRecords, + page: () => const ShareRecordsPage(), + middlewares: [PageStandardsMiddleware()], + ), GetPage( name: dateCalculator, page: () => const DateCalculatorPage(), @@ -556,6 +572,11 @@ class AppRoutes { page: () => const OrderAssistantPage(), middlewares: [PageStandardsMiddleware()], ), + GetPage( + name: toolsDecisionMaker, + page: () => const DecisionMakerPage(), + middlewares: [PageStandardsMiddleware()], + ), ]; static void registerAllPages() { @@ -1183,6 +1204,18 @@ class AppRoutes { ], builder: () => const EmailHistoryPage(), ), + PageInfo( + route: shareRecords, + name: 'Share Records Page', + description: '分享记录页面', + requiredStandards: const [ + StandardCheck.themeColors, + StandardCheck.textColors, + StandardCheck.fontSize, + StandardCheck.darkMode, + ], + builder: () => const ShareRecordsPage(), + ), PageInfo( route: dateCalculator, name: 'Date Calculator Page', diff --git a/lib/src/controllers/home/home_controller.dart b/lib/src/controllers/browse/home_controller.dart similarity index 100% rename from lib/src/controllers/home/home_controller.dart rename to lib/src/controllers/browse/home_controller.dart diff --git a/lib/src/controllers/home/main_navigation_controller.dart b/lib/src/controllers/browse/main_navigation_controller.dart similarity index 100% rename from lib/src/controllers/home/main_navigation_controller.dart rename to lib/src/controllers/browse/main_navigation_controller.dart diff --git a/lib/src/controllers/recipe/recipe_detail_controller.dart b/lib/src/controllers/browse/recipe_detail_controller.dart similarity index 90% rename from lib/src/controllers/recipe/recipe_detail_controller.dart rename to lib/src/controllers/browse/recipe_detail_controller.dart index 3c49723..7e85eb6 100644 --- a/lib/src/controllers/recipe/recipe_detail_controller.dart +++ b/lib/src/controllers/browse/recipe_detail_controller.dart @@ -5,10 +5,12 @@ import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; import 'package:mom_kitchen/src/controllers/base_controller.dart'; import 'package:mom_kitchen/src/controllers/data/browse_history_controller.dart'; +import 'package:mom_kitchen/src/controllers/data/share_record_controller.dart'; import 'package:mom_kitchen/src/controllers/feed/action_controller.dart'; import 'package:mom_kitchen/src/controllers/data/favorites_controller.dart'; import 'package:mom_kitchen/src/controllers/data/shopping_list_controller.dart'; import 'package:mom_kitchen/src/models/data/browse_history_model.dart'; +import 'package:mom_kitchen/src/models/data/share_record_model.dart'; import 'package:mom_kitchen/src/models/feed_item_model.dart'; import 'package:mom_kitchen/src/models/recipe/recipe_model.dart'; import 'package:mom_kitchen/src/models/data/shopping_item_model.dart'; @@ -323,5 +325,34 @@ class RecipeDetailController extends BaseController { shareText.write('— 来自 小妈厨房 App 🍳'); AppUtils.shareContent(shareText.toString(), subject: '🍳 ${r.title}'); + + _recordShare(r); + } + + void _recordShare(RecipeModel r) { + try { + if (!Get.isRegistered()) { + Get.put(ShareRecordController()); + } + final controller = Get.find(); + final record = ShareRecordModel( + id: DateTime.now().millisecondsSinceEpoch.toString(), + recipeId: r.id.toString(), + recipeTitle: r.title, + recipeCode: r.code, + categoryName: r.categoryName, + shareType: r.hasCode ? ShareType.link : ShareType.text, + shareUrl: r.hasCode + ? 'https://eat.wktyl.com/recipe/${r.code}' + : null, + ratingScore: r.rating?.score, + viewCount: viewCount.value > 0 ? viewCount.value : null, + likeCount: likeCount.value > 0 ? likeCount.value : null, + sharedAt: DateTime.now().toIso8601String(), + ); + controller.addRecord(record); + } catch (e) { + debugPrint('记录分享历史失败: $e'); + } } } diff --git a/lib/src/controllers/recipe/search_controller.dart b/lib/src/controllers/browse/search_controller.dart similarity index 100% rename from lib/src/controllers/recipe/search_controller.dart rename to lib/src/controllers/browse/search_controller.dart diff --git a/lib/src/controllers/data/rating_records_controller.dart b/lib/src/controllers/data/rating_records_controller.dart new file mode 100644 index 0000000..087b889 --- /dev/null +++ b/lib/src/controllers/data/rating_records_controller.dart @@ -0,0 +1,308 @@ +// 2026-04-19 | RatingRecordsController | 评分记录控制器 | 管理用户评分记录,支持Hive持久化 +// 2026-04-19 | 初始创建:评分记录增删查改、排序、搜索、批量删除、统计 + +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/controllers/base_controller.dart'; +import 'package:mom_kitchen/src/controllers/feed/action_controller.dart'; +import 'package:mom_kitchen/src/models/data/rating_record_model.dart'; +import 'package:mom_kitchen/src/services/data/hive_service.dart'; +import 'package:mom_kitchen/src/services/ui/toast_service.dart'; + +class RatingRecordsController extends BaseController { + final RxMap _records = {}.obs; + final Rx sortMode = RatingSortMode.newest.obs; + final RxSet selectedIds = {}.obs; + final RxBool isEditMode = false.obs; + final RxString searchQuery = ''.obs; + final RxInt filterScore = 0.obs; + + List get records => _getSortedRecords(); + List get allRecords => _records.values.toList(); + int get count => _records.length; + int get selectedCount => selectedIds.length; + bool get hasSelection => selectedIds.isNotEmpty; + bool get isSearching => searchQuery.value.isNotEmpty; + bool get isFiltering => filterScore.value > 0; + + @override + void onInit() { + super.onInit(); + _loadFromHive(); + _syncWithActionController(); + } + + void _loadFromHive() { + try { + final hive = HiveService(); + if (!hive.isInitialized) return; + + final allData = hive.getAllRatingRecords(); + for (final data in allData) { + final recipeId = data['recipeId'] as int?; + if (recipeId != null) { + _records[recipeId] = RatingRecordModel.fromJson(data); + } + } + } catch (e) { + debugPrint('RatingRecordsController: _loadFromHive failed: $e'); + } + } + + void _syncWithActionController() { + try { + if (!Get.isRegistered()) return; + final actionController = Get.find(); + final ratedItems = actionController.ratedItems; + + for (final entry in ratedItems.entries) { + final recipeId = entry.key; + final score = entry.value; + if (!_records.containsKey(recipeId)) { + addRecord( + recipeId: recipeId, + recipeTitle: '菜谱 #$recipeId', + score: score, + ); + } + } + } catch (e) { + debugPrint('RatingRecordsController: _syncWithActionController failed: $e'); + } + } + + void addRecord({ + required int recipeId, + required String recipeTitle, + String? coverImage, + String? categoryName, + required int score, + String? type, + }) { + final now = DateTime.now().toIso8601String(); + final record = RatingRecordModel( + id: 'rating_${recipeId}_$now', + recipeId: recipeId, + recipeTitle: recipeTitle, + coverImage: coverImage, + categoryName: categoryName, + score: score, + ratedAt: now, + type: type ?? 'recipe', + ); + + _records[recipeId] = record; + _saveToHive(record); + } + + void updateRecordScore(int recipeId, int newScore) { + final record = _records[recipeId]; + if (record == null) return; + + final updated = record.copyWith( + score: newScore, + ratedAt: DateTime.now().toIso8601String(), + ); + _records[recipeId] = updated; + _saveToHive(updated); + + try { + if (Get.isRegistered()) { + final actionController = Get.find(); + actionController.ratedItems[recipeId] = newScore; + } + } catch (_) {} + + ToastService.show(message: '评分已更新为 $newScore ⭐'); + } + + void removeRecord(int recipeId) { + if (_records.containsKey(recipeId)) { + _records.remove(recipeId); + _removeFromHive(recipeId); + + try { + if (Get.isRegistered()) { + final actionController = Get.find(); + actionController.ratedItems.remove(recipeId); + } + } catch (_) {} + + ToastService.show(message: '已删除评分记录 🗑️'); + } + } + + void clearAll() { + _records.clear(); + _clearHive(); + ToastService.show(message: '已清空所有评分记录'); + } + + RatingRecordModel? getRecord(int recipeId) => _records[recipeId]; + + bool hasRecord(int recipeId) => _records.containsKey(recipeId); + + List _getSortedRecords() { + var items = _records.values.toList(); + + if (searchQuery.value.isNotEmpty) { + final query = searchQuery.value.toLowerCase(); + items = items.where((e) { + final title = e.recipeTitle.toLowerCase(); + final category = (e.categoryName ?? '').toLowerCase(); + return title.contains(query) || category.contains(query); + }).toList(); + } + + if (filterScore.value > 0) { + items = items.where((e) => e.score == filterScore.value).toList(); + } + + switch (sortMode.value) { + case RatingSortMode.newest: + items.sort((a, b) => b.ratedAt.compareTo(a.ratedAt)); + break; + case RatingSortMode.oldest: + items.sort((a, b) => a.ratedAt.compareTo(b.ratedAt)); + break; + case RatingSortMode.scoreHigh: + items.sort((a, b) => b.score.compareTo(a.score)); + break; + case RatingSortMode.scoreLow: + items.sort((a, b) => a.score.compareTo(b.score)); + break; + } + return items; + } + + void setSearchQuery(String query) { + searchQuery.value = query; + _records.refresh(); + } + + void clearSearch() { + searchQuery.value = ''; + _records.refresh(); + } + + void setFilterScore(int score) { + filterScore.value = score; + _records.refresh(); + } + + void clearFilter() { + filterScore.value = 0; + _records.refresh(); + } + + void setSortMode(RatingSortMode mode) { + sortMode.value = mode; + _records.refresh(); + } + + Map get statistics { + final stats = { + 'total': _records.length, + 'avgScore': 0.0, + '5': 0, + '4': 0, + '3': 0, + '2': 0, + '1': 0, + }; + + if (_records.isEmpty) return stats; + + var totalScore = 0; + for (final record in _records.values) { + totalScore += record.score; + stats['${record.score}'] = (stats['${record.score}'] ?? 0) + 1; + } + stats['avgScore'] = (totalScore / _records.length).toStringAsFixed(1); + + return stats; + } + + String exportToJson() { + final data = _records.values.map((e) => e.toJson()).toList(); + return const JsonEncoder.withIndent(' ').convert(data); + } + + String exportToCsv() { + final buffer = StringBuffer(); + buffer.writeln('菜谱ID,菜谱名称,分类,评分,评分等级,评分时间'); + for (final item in _records.values) { + buffer.writeln( + [ + item.recipeId, + '"${item.recipeTitle.replaceAll('"', '""')}"', + item.categoryName ?? '', + item.score, + item.scoreLabel, + item.ratedAt, + ].join(','), + ); + } + return buffer.toString(); + } + + void toggleEditMode() { + isEditMode.value = !isEditMode.value; + if (!isEditMode.value) { + selectedIds.clear(); + } + } + + void toggleSelection(int recipeId) { + if (selectedIds.contains(recipeId)) { + selectedIds.remove(recipeId); + } else { + selectedIds.add(recipeId); + } + } + + void selectAll() { + selectedIds.clear(); + selectedIds.addAll(_records.keys); + } + + void deselectAll() { + selectedIds.clear(); + } + + void deleteSelected() { + final count = selectedIds.length; + for (final id in selectedIds.toList()) { + _records.remove(id); + _removeFromHive(id); + } + selectedIds.clear(); + isEditMode.value = false; + ToastService.show(message: '已删除 $count 条评分记录 🗑️'); + } + + void _saveToHive(RatingRecordModel record) { + try { + final hive = HiveService(); + if (!hive.isInitialized) return; + hive.addRatingRecord(record.recipeId, record.toJson()); + } catch (_) {} + } + + void _removeFromHive(int recipeId) { + try { + final hive = HiveService(); + if (!hive.isInitialized) return; + hive.removeRatingRecord(recipeId); + } catch (_) {} + } + + void _clearHive() { + try { + final hive = HiveService(); + if (!hive.isInitialized) return; + hive.clearAllRatingRecords(); + } catch (_) {} + } +} diff --git a/lib/src/controllers/data/share_record_controller.dart b/lib/src/controllers/data/share_record_controller.dart new file mode 100644 index 0000000..614a5db --- /dev/null +++ b/lib/src/controllers/data/share_record_controller.dart @@ -0,0 +1,150 @@ +// 2026-04-19 | share_record_controller.dart | 分享记录控制器 | 管理菜谱分享历史 +// 2026-04-19 | 初始创建,使用 SharedPreferences JSON 持久化 + +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; +import '../../models/data/share_record_model.dart'; + +class ShareRecordController extends GetxController { + static ShareRecordController get to => Get.find(); + + final RxList _records = [].obs; + static const String _storageKey = 'share_records'; + static const int _maxRecordCount = 200; + + final RxString _searchQuery = ''.obs; + final Rx _filterType = Rx(null); + + List get records => _records; + String get searchQuery => _searchQuery.value; + ShareType? get filterType => _filterType.value; + + int get count => _records.length; + + int get textShareCount => + _records.where((r) => r.shareType == ShareType.text).length; + int get linkShareCount => + _records.where((r) => r.shareType == ShareType.link).length; + int get qrShareCount => + _records.where((r) => r.shareType == ShareType.qr).length; + int get emailShareCount => + _records.where((r) => r.shareType == ShareType.email).length; + + List get filteredRecords { + var result = _records.toList(); + if (_filterType.value != null) { + result = result.where((r) => r.shareType == _filterType.value).toList(); + } + if (_searchQuery.value.isNotEmpty) { + final query = _searchQuery.value.toLowerCase(); + result = result.where((r) { + return r.recipeTitle.toLowerCase().contains(query) || + (r.categoryName?.toLowerCase().contains(query) ?? false) || + (r.note?.toLowerCase().contains(query) ?? false); + }).toList(); + } + return result; + } + + SharedPreferences? _prefs; + + @override + void onInit() { + super.onInit(); + _initPrefs(); + } + + Future _initPrefs() async { + try { + _prefs = await SharedPreferences.getInstance(); + await loadRecords(); + debugPrint('分享记录初始化完成,共 ${_records.length} 条记录'); + } catch (e) { + debugPrint('初始化SharedPreferences失败: $e'); + } + } + + Future loadRecords() async { + try { + _prefs ??= await SharedPreferences.getInstance(); + final String? data = _prefs!.getString(_storageKey); + if (data != null && data.isNotEmpty) { + final List jsonList = json.decode(data); + final loaded = jsonList + .map((json) => ShareRecordModel.fromJson(json)) + .toList(); + _records.assignAll(loaded); + debugPrint('从本地加载 ${loaded.length} 条分享记录'); + } + } catch (e) { + debugPrint('加载分享记录失败: $e'); + } + } + + Future addRecord(ShareRecordModel record) async { + try { + _records.insert(0, record); + if (_records.length > _maxRecordCount) { + _records.removeRange(_maxRecordCount, _records.length); + } + await _saveRecords(); + debugPrint('添加分享记录: ${record.recipeTitle}'); + } catch (e) { + debugPrint('添加分享记录失败: $e'); + } + } + + Future removeRecord(String id) async { + try { + _records.removeWhere((r) => r.id == id); + await _saveRecords(); + } catch (e) { + debugPrint('删除分享记录失败: $e'); + } + } + + Future clearRecords() async { + try { + _records.clear(); + await _saveRecords(); + } catch (e) { + debugPrint('清空分享记录失败: $e'); + } + } + + void setSearchQuery(String query) { + _searchQuery.value = query; + } + + void clearSearch() { + _searchQuery.value = ''; + } + + void setFilterType(ShareType? type) { + _filterType.value = type; + } + + Future _saveRecords() async { + try { + _prefs ??= await SharedPreferences.getInstance(); + final data = json.encode(_records.map((r) => r.toJson()).toList()); + await _prefs!.setString(_storageKey, data); + } catch (e) { + debugPrint('保存分享记录失败: $e'); + } + } + + List getRecordsByDate(String date) { + return _records.where((r) => r.sharedAt.startsWith(date)).toList(); + } + + List getRecordsByType(ShareType type) { + return _records.where((r) => r.shareType == type).toList(); + } + + bool hasSharedRecipe(String recipeId) { + return _records.any((r) => r.recipeId == recipeId); + } +} diff --git a/lib/src/controllers/ingredient_manage_controller.dart b/lib/src/controllers/tools/ingredient_manage_controller.dart similarity index 100% rename from lib/src/controllers/ingredient_manage_controller.dart rename to lib/src/controllers/tools/ingredient_manage_controller.dart diff --git a/lib/src/controllers/data/email_history_controller.dart b/lib/src/controllers/user/email_history_controller.dart similarity index 100% rename from lib/src/controllers/data/email_history_controller.dart rename to lib/src/controllers/user/email_history_controller.dart diff --git a/lib/src/controllers/data/favorites_controller.dart b/lib/src/controllers/user/favorites_controller.dart similarity index 100% rename from lib/src/controllers/data/favorites_controller.dart rename to lib/src/controllers/user/favorites_controller.dart diff --git a/lib/src/models/api_response.dart b/lib/src/models/app/api_response.dart similarity index 100% rename from lib/src/models/api_response.dart rename to lib/src/models/app/api_response.dart diff --git a/lib/src/models/bottle_model.dart b/lib/src/models/app/bottle_model.dart similarity index 100% rename from lib/src/models/bottle_model.dart rename to lib/src/models/app/bottle_model.dart diff --git a/lib/src/models/tools/order_model.dart b/lib/src/models/app/order_model.dart similarity index 100% rename from lib/src/models/tools/order_model.dart rename to lib/src/models/app/order_model.dart diff --git a/lib/src/models/tool_item_model.dart b/lib/src/models/app/tool_item_model.dart similarity index 96% rename from lib/src/models/tool_item_model.dart rename to lib/src/models/app/tool_item_model.dart index cad8ee0..7ed42a3 100644 --- a/lib/src/models/tool_item_model.dart +++ b/lib/src/models/app/tool_item_model.dart @@ -8,6 +8,7 @@ * 更新: 2026-04-15 新增日期加减计算器工具 * 更新: 2026-04-15 新增吃货文案生成器工具 * 更新: 2026-04-17 新增 waterfallSlot 必填字段,不声明编译报错;新增 homeCardTools getter + * 更新: 2026-04-19 新增帮我做决定工具 */ import 'package:mom_kitchen/src/models/waterfall_slot.dart'; @@ -334,6 +335,16 @@ class ToolRegistry { description: '点餐推单,二维码分享', waterfallSlot: WaterfallSlotConfig(show: true, priority: 9), ), + ToolItem( + id: 'decision_maker', + name: '帮我做决定', + icon: '🎯', + needsNetwork: false, + category: 'cooking', + route: '/tools/decision-maker', + description: '转盘随机决策,选择困难终结者', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 2, badge: 'NEW'), + ), ToolItem( id: 'farm_game', name: '小妈菜园', diff --git a/lib/src/models/data/cooking_tip_model.dart b/lib/src/models/data/plan/cooking_tip_model.dart similarity index 100% rename from lib/src/models/data/cooking_tip_model.dart rename to lib/src/models/data/plan/cooking_tip_model.dart diff --git a/lib/src/models/data/daily_menu_model.dart b/lib/src/models/data/plan/daily_menu_model.dart similarity index 100% rename from lib/src/models/data/daily_menu_model.dart rename to lib/src/models/data/plan/daily_menu_model.dart diff --git a/lib/src/models/data/shopping_item_model.dart b/lib/src/models/data/plan/shopping_item_model.dart similarity index 100% rename from lib/src/models/data/shopping_item_model.dart rename to lib/src/models/data/plan/shopping_item_model.dart diff --git a/lib/src/models/data/weekly_menu_model.dart b/lib/src/models/data/plan/weekly_menu_model.dart similarity index 100% rename from lib/src/models/data/weekly_menu_model.dart rename to lib/src/models/data/plan/weekly_menu_model.dart diff --git a/lib/src/models/data/browse_history_model.dart b/lib/src/models/data/record/browse_history_model.dart similarity index 100% rename from lib/src/models/data/browse_history_model.dart rename to lib/src/models/data/record/browse_history_model.dart diff --git a/lib/src/models/data/cooking_note_model.dart b/lib/src/models/data/record/cooking_note_model.dart similarity index 100% rename from lib/src/models/data/cooking_note_model.dart rename to lib/src/models/data/record/cooking_note_model.dart diff --git a/lib/src/models/data/email_record_model.dart b/lib/src/models/data/record/email_record_model.dart similarity index 100% rename from lib/src/models/data/email_record_model.dart rename to lib/src/models/data/record/email_record_model.dart diff --git a/lib/src/models/data/meal_record_model.dart b/lib/src/models/data/record/meal_record_model.dart similarity index 100% rename from lib/src/models/data/meal_record_model.dart rename to lib/src/models/data/record/meal_record_model.dart diff --git a/lib/src/models/data/record/rating_record_model.dart b/lib/src/models/data/record/rating_record_model.dart new file mode 100644 index 0000000..b672d31 --- /dev/null +++ b/lib/src/models/data/record/rating_record_model.dart @@ -0,0 +1,184 @@ +// 2026-04-19 | RatingRecordModel | 评分记录模型 | 记录用户评分历史,支持Hive持久化 +// 2026-04-19 | 初始创建:支持菜谱评分记录的本地持久化存储 + +import 'package:hive_ce/hive.dart'; + +class RatingRecordAdapter extends TypeAdapter { + @override + final int typeId = 5; + + @override + RatingRecordModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (var i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return RatingRecordModel( + id: fields[0] as String, + recipeId: fields[1] as int, + recipeTitle: fields[2] as String, + coverImage: fields[3] as String?, + categoryName: fields[4] as String?, + score: fields[5] as int, + ratedAt: fields[6] as String, + type: fields[7] as String? ?? 'recipe', + ); + } + + @override + void write(BinaryWriter writer, RatingRecordModel obj) { + writer + ..writeByte(8) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.recipeId) + ..writeByte(2) + ..write(obj.recipeTitle) + ..writeByte(3) + ..write(obj.coverImage) + ..writeByte(4) + ..write(obj.categoryName) + ..writeByte(5) + ..write(obj.score) + ..writeByte(6) + ..write(obj.ratedAt) + ..writeByte(7) + ..write(obj.type); + } +} + +enum RatingSortMode { newest, oldest, scoreHigh, scoreLow } + +class RatingRecordModel { + final String id; + final int recipeId; + final String recipeTitle; + final String? coverImage; + final String? categoryName; + final int score; + final String ratedAt; + final String type; + + const RatingRecordModel({ + required this.id, + required this.recipeId, + required this.recipeTitle, + this.coverImage, + this.categoryName, + required this.score, + required this.ratedAt, + this.type = 'recipe', + }); + + bool get isHighScore => score >= 4; + bool get isMediumScore => score == 3; + bool get isLowScore => score <= 2; + + String get scoreLabel { + switch (score) { + case 5: + return '完美'; + case 4: + return '推荐'; + case 3: + return '一般'; + case 2: + return '较差'; + case 1: + return '不推荐'; + default: + return ''; + } + } + + String get scoreEmoji { + switch (score) { + case 5: + return '🌟'; + case 4: + return '⭐'; + case 3: + return '👍'; + case 2: + return '😐'; + case 1: + return '👎'; + default: + return ''; + } + } + + String get displayDate { + if (ratedAt.isEmpty) return ''; + try { + final dt = DateTime.parse(ratedAt); + final now = DateTime.now(); + final diff = now.difference(dt); + + if (diff.inDays == 0) { + if (diff.inHours == 0) { + if (diff.inMinutes == 0) return '刚刚'; + return '${diff.inMinutes}分钟前'; + } + return '${diff.inHours}小时前'; + } else if (diff.inDays == 1) { + return '昨天'; + } else if (diff.inDays < 7) { + return '${diff.inDays}天前'; + } else { + return '${dt.month}月${dt.day}日'; + } + } catch (_) { + return ratedAt; + } + } + + RatingRecordModel copyWith({ + String? id, + int? recipeId, + String? recipeTitle, + String? coverImage, + String? categoryName, + int? score, + String? ratedAt, + String? type, + }) { + return RatingRecordModel( + id: id ?? this.id, + recipeId: recipeId ?? this.recipeId, + recipeTitle: recipeTitle ?? this.recipeTitle, + coverImage: coverImage ?? this.coverImage, + categoryName: categoryName ?? this.categoryName, + score: score ?? this.score, + ratedAt: ratedAt ?? this.ratedAt, + type: type ?? this.type, + ); + } + + Map toJson() { + return { + 'id': id, + 'recipeId': recipeId, + 'recipeTitle': recipeTitle, + 'coverImage': coverImage, + 'categoryName': categoryName, + 'score': score, + 'ratedAt': ratedAt, + 'type': type, + }; + } + + factory RatingRecordModel.fromJson(Map json) { + return RatingRecordModel( + id: json['id'] as String? ?? '', + recipeId: json['recipeId'] as int? ?? 0, + recipeTitle: json['recipeTitle'] as String? ?? '', + coverImage: json['coverImage'] as String?, + categoryName: json['categoryName'] as String?, + score: json['score'] as int? ?? 0, + ratedAt: json['ratedAt'] as String? ?? '', + type: json['type'] as String? ?? 'recipe', + ); + } +} diff --git a/lib/src/models/data/record/share_record_model.dart b/lib/src/models/data/record/share_record_model.dart new file mode 100644 index 0000000..d76abf7 --- /dev/null +++ b/lib/src/models/data/record/share_record_model.dart @@ -0,0 +1,154 @@ +// 2026-04-19 | share_record_model.dart | 分享记录模型 | 记录菜谱分享历史 +// 2026-04-19 | 初始创建,支持 SharedPreferences JSON 持久化 + +enum ShareType { + text, + link, + qr, + email, +} + +class ShareRecordModel { + final String id; + final String recipeId; + final String recipeTitle; + final String? recipeCode; + final String? categoryName; + final ShareType shareType; + final String? shareUrl; + final double? ratingScore; + final int? viewCount; + final int? likeCount; + final String sharedAt; + final String? note; + + const ShareRecordModel({ + required this.id, + required this.recipeId, + required this.recipeTitle, + this.recipeCode, + this.categoryName, + required this.shareType, + this.shareUrl, + this.ratingScore, + this.viewCount, + this.likeCount, + required this.sharedAt, + this.note, + }); + + String get displayDate { + if (sharedAt.isEmpty) return ''; + try { + final dt = DateTime.parse(sharedAt); + final now = DateTime.now(); + final diff = now.difference(dt); + + if (diff.inDays == 0) { + if (diff.inHours == 0) { + if (diff.inMinutes == 0) return '刚刚'; + return '${diff.inMinutes}分钟前'; + } + return '${diff.inHours}小时前'; + } else if (diff.inDays == 1) { + return '昨天'; + } else if (diff.inDays < 7) { + return '${diff.inDays}天前'; + } else { + return '${dt.month}月${dt.day}日'; + } + } catch (_) { + return sharedAt; + } + } + + String get typeIcon { + switch (shareType) { + case ShareType.text: + return '📝'; + case ShareType.link: + return '🔗'; + case ShareType.qr: + return '📱'; + case ShareType.email: + return '📧'; + } + } + + String get typeLabel { + switch (shareType) { + case ShareType.text: + return '文本分享'; + case ShareType.link: + return '链接分享'; + case ShareType.qr: + return '二维码分享'; + case ShareType.email: + return '邮件分享'; + } + } + + ShareRecordModel copyWith({ + String? id, + String? recipeId, + String? recipeTitle, + String? recipeCode, + String? categoryName, + ShareType? shareType, + String? shareUrl, + double? ratingScore, + int? viewCount, + int? likeCount, + String? sharedAt, + String? note, + }) { + return ShareRecordModel( + id: id ?? this.id, + recipeId: recipeId ?? this.recipeId, + recipeTitle: recipeTitle ?? this.recipeTitle, + recipeCode: recipeCode ?? this.recipeCode, + categoryName: categoryName ?? this.categoryName, + shareType: shareType ?? this.shareType, + shareUrl: shareUrl ?? this.shareUrl, + ratingScore: ratingScore ?? this.ratingScore, + viewCount: viewCount ?? this.viewCount, + likeCount: likeCount ?? this.likeCount, + sharedAt: sharedAt ?? this.sharedAt, + note: note ?? this.note, + ); + } + + Map toJson() { + return { + 'id': id, + 'recipeId': recipeId, + 'recipeTitle': recipeTitle, + 'recipeCode': recipeCode, + 'categoryName': categoryName, + 'shareType': shareType.index, + 'shareUrl': shareUrl, + 'ratingScore': ratingScore, + 'viewCount': viewCount, + 'likeCount': likeCount, + 'sharedAt': sharedAt, + 'note': note, + }; + } + + factory ShareRecordModel.fromJson(Map json) { + return ShareRecordModel( + id: json['id'] as String? ?? '', + recipeId: json['recipeId'] as String? ?? '', + recipeTitle: json['recipeTitle'] as String? ?? '', + recipeCode: json['recipeCode'] as String?, + categoryName: json['categoryName'] as String?, + shareType: ShareType.values[json['shareType'] as int? ?? 0], + shareUrl: json['shareUrl'] as String?, + ratingScore: (json['ratingScore'] as num?)?.toDouble(), + viewCount: json['viewCount'] as int?, + likeCount: json['likeCount'] as int?, + sharedAt: json['sharedAt'] as String? ?? '', + note: json['note'] as String?, + ); + } +} diff --git a/lib/src/models/farm/achievement_config.dart b/lib/src/models/farm/config/achievement_config.dart similarity index 100% rename from lib/src/models/farm/achievement_config.dart rename to lib/src/models/farm/config/achievement_config.dart diff --git a/lib/src/models/farm/achievement_registry.dart b/lib/src/models/farm/config/achievement_registry.dart similarity index 100% rename from lib/src/models/farm/achievement_registry.dart rename to lib/src/models/farm/config/achievement_registry.dart diff --git a/lib/src/models/farm/crop_config.dart b/lib/src/models/farm/config/crop_config.dart similarity index 100% rename from lib/src/models/farm/crop_config.dart rename to lib/src/models/farm/config/crop_config.dart diff --git a/lib/src/models/farm/crop_registry.dart b/lib/src/models/farm/config/crop_registry.dart similarity index 100% rename from lib/src/models/farm/crop_registry.dart rename to lib/src/models/farm/config/crop_registry.dart diff --git a/lib/src/models/farm/farm_land.dart b/lib/src/models/farm/entity/farm_land.dart similarity index 100% rename from lib/src/models/farm/farm_land.dart rename to lib/src/models/farm/entity/farm_land.dart diff --git a/lib/src/models/farm/farm_land.g.dart b/lib/src/models/farm/entity/farm_land.g.dart similarity index 100% rename from lib/src/models/farm/farm_land.g.dart rename to lib/src/models/farm/entity/farm_land.g.dart diff --git a/lib/src/models/farm/farm_player.dart b/lib/src/models/farm/entity/farm_player.dart similarity index 100% rename from lib/src/models/farm/farm_player.dart rename to lib/src/models/farm/entity/farm_player.dart diff --git a/lib/src/models/farm/farm_player.g.dart b/lib/src/models/farm/entity/farm_player.g.dart similarity index 100% rename from lib/src/models/farm/farm_player.g.dart rename to lib/src/models/farm/entity/farm_player.g.dart diff --git a/lib/src/models/farm/inventory_item.dart b/lib/src/models/farm/entity/inventory_item.dart similarity index 100% rename from lib/src/models/farm/inventory_item.dart rename to lib/src/models/farm/entity/inventory_item.dart diff --git a/lib/src/models/farm/inventory_item.g.dart b/lib/src/models/farm/entity/inventory_item.g.dart similarity index 100% rename from lib/src/models/farm/inventory_item.g.dart rename to lib/src/models/farm/entity/inventory_item.g.dart diff --git a/lib/src/models/discover_model.dart b/lib/src/models/feed/discover_model.dart similarity index 100% rename from lib/src/models/discover_model.dart rename to lib/src/models/feed/discover_model.dart diff --git a/lib/src/models/dish_rank_model.dart b/lib/src/models/feed/dish_rank_model.dart similarity index 100% rename from lib/src/models/dish_rank_model.dart rename to lib/src/models/feed/dish_rank_model.dart diff --git a/lib/src/models/feed_item_model.dart b/lib/src/models/feed/feed_item_model.dart similarity index 100% rename from lib/src/models/feed_item_model.dart rename to lib/src/models/feed/feed_item_model.dart diff --git a/lib/src/models/mini_card_model.dart b/lib/src/models/feed/mini_card_model.dart similarity index 100% rename from lib/src/models/mini_card_model.dart rename to lib/src/models/feed/mini_card_model.dart diff --git a/lib/src/models/waterfall_slot.dart b/lib/src/models/feed/waterfall_slot.dart similarity index 100% rename from lib/src/models/waterfall_slot.dart rename to lib/src/models/feed/waterfall_slot.dart diff --git a/lib/src/models/weight_record_model.dart b/lib/src/models/user/weight_record_model.dart similarity index 100% rename from lib/src/models/weight_record_model.dart rename to lib/src/models/user/weight_record_model.dart diff --git a/lib/src/pages/discover/components/discover_sections_widget.dart b/lib/src/pages/discover/components/discover_sections_widget.dart index 38c723c..f3af9d2 100644 --- a/lib/src/pages/discover/components/discover_sections_widget.dart +++ b/lib/src/pages/discover/components/discover_sections_widget.dart @@ -156,12 +156,8 @@ class DiscoverSectionsWidget extends StatelessWidget { Get.toNamed('/recipe-detail', arguments: '${recipe.id}'); return false; } else { - return true; - } - }, - onDismissed: (direction) { - if (direction == DismissDirection.startToEnd) { _showQuickActions(context, recipe); + return false; } }, background: Container( diff --git a/lib/src/pages/discover/discover_page.dart.bak b/lib/src/pages/discover/discover_page.dart.bak deleted file mode 100644 index c7be568..0000000 --- a/lib/src/pages/discover/discover_page.dart.bak +++ /dev/null @@ -1,1834 +0,0 @@ -/* - * 文件: discover_page.dart - * 名称: 发现页面 - * 作用: iOS 26 风格的发现页面,整合热门排行+今天吃什么+搜索,支持下拉进入工具中心 - * 更新: 2026-04-10 购物清单入口添加 Badge 显示数量 - * 更新: 2026-04-13 推荐tab新增口味/工艺标签入口,修复分类导航 - * 更新: 2026-04-16 新增下拉进入工具中心功能,移除顶部4个固定按钮至收藏页 - */ - -import 'dart:ui'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:mom_kitchen/src/config/design_tokens.dart'; -import 'package:mom_kitchen/src/config/app_routes.dart'; -import 'package:mom_kitchen/src/models/recipe/category_model.dart'; -import 'package:mom_kitchen/src/models/recipe/tag_model.dart'; -import 'package:mom_kitchen/src/repositories/recipe_repository.dart'; -import 'package:mom_kitchen/src/widgets/glass/glass_search_bar.dart'; -import 'package:mom_kitchen/src/widgets/glass/glass_segmented_control.dart'; -import 'package:mom_kitchen/src/controllers/feed/hot_controller.dart'; -import 'package:mom_kitchen/src/controllers/tools/tools_controller.dart'; -import 'package:mom_kitchen/src/models/tool_item_model.dart'; -import 'package:mom_kitchen/src/services/ui/toast_service.dart'; -import 'package:mom_kitchen/src/repositories/hot_repository.dart' as repo; - -class DiscoverPage extends StatefulWidget { - const DiscoverPage({super.key}); - - @override - State createState() => _DiscoverPageState(); -} - -class _DiscoverPageState extends State - with SingleTickerProviderStateMixin { - int _segmentIndex = 0; - int _recommendTypeIndex = 0; - int _recommendSubIndex = 0; - late HotController _hotController; - final RecipeRepository _recipeRepo = RecipeRepository(); - List _topCategories = []; - List _ingredientCategories = []; - List _tasteTags = []; - List _cookingTags = []; - bool _isLoadingCategories = true; - - ToolsController? _toolsController; - - static const double _pullThreshold = 80.0; - - double _pullOffset = 0.0; - bool _isPanelOpen = false; - bool _isPulling = false; - double _panelHeight = 0.0; - - late final AnimationController _panelController; - late final Animation _panelAnimation; - AnimationStatusListener? _panelStatusListener; - - final ScrollController _scrollController = ScrollController(); - - @override - void initState() { - super.initState(); - _hotController = Get.find(); - _loadCategories(); - _initToolsController(); - - _panelController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 350), - ); - _panelAnimation = CurvedAnimation( - parent: _panelController, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ); - _panelStatusListener = (status) { - if (status == AnimationStatus.dismissed) { - setState(() => _isPanelOpen = false); - } else if (status == AnimationStatus.completed) { - setState(() => _isPanelOpen = true); - } - }; - _panelController.addStatusListener(_panelStatusListener!); - } - - void _initToolsController() { - try { - if (Get.isRegistered()) { - _toolsController = Get.find(); - } - } catch (e) { - debugPrint('DiscoverPage: ToolsController init error: $e'); - } - } - - @override - void dispose() { - if (_panelStatusListener != null) { - _panelController.removeStatusListener(_panelStatusListener!); - } - _panelController.dispose(); - _scrollController.dispose(); - super.dispose(); - } - - Future _loadCategories() async { - try { - final categories = await _recipeRepo.fetchCategories(); - final ingredientCategories = await _recipeRepo.fetchCategories( - type: 'ingredient', - ); - final tasteTags = await _recipeRepo.fetchTasteTags(); - final cookingTags = await _recipeRepo.fetchCookingTags(); - if (mounted) { - setState(() { - _topCategories = categories; - _ingredientCategories = ingredientCategories; - _tasteTags = tasteTags; - _cookingTags = cookingTags; - _isLoadingCategories = false; - }); - } - } catch (e) { - debugPrint('DiscoverPage loadCategories error: $e'); - if (mounted) setState(() => _isLoadingCategories = false); - } - } - - void _openPanel() { - _panelController.forward(); - } - - void _closePanel() { - _panelController.reverse(); - } - - bool _handleScrollNotification(ScrollNotification notification) { - if (_isPanelOpen) return false; - - if (notification is OverscrollNotification) { - if (notification.overscroll < 0) { - _isPulling = true; - setState(() { - _pullOffset = (_pullOffset + (-notification.overscroll) * 0.5).clamp( - 0.0, - _panelMaxHeight, - ); - }); - if (_pullOffset >= _pullThreshold) { - _pullOffset = 0; - _isPulling = false; - _openPanel(); - } - return true; - } - } else if (notification is ScrollEndNotification) { - if (_pullOffset > 0 && _pullOffset < _pullThreshold) { - setState(() => _pullOffset = 0); - } - _isPulling = false; - } else if (notification is ScrollUpdateNotification) { - if (_isPulling && - notification.scrollDelta != null && - notification.scrollDelta! > 0) { - setState(() { - _pullOffset = (_pullOffset - notification.scrollDelta! * 0.5).clamp( - 0.0, - _panelMaxHeight, - ); - }); - } - } - - return false; - } - - @override - Widget build(BuildContext context) { - final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; - - return CupertinoPageScaffold( - backgroundColor: isDark - ? DarkDesignTokens.background - : DesignTokens.background, - child: SafeArea( - child: Stack( - children: [ - Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - vertical: DesignTokens.space3, - ), - child: Row( - children: [ - Text( - '发现', - style: TextStyle( - fontSize: DesignTokens.fontXxl, - fontWeight: FontWeight.w700, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, - ), - ), - const SizedBox(width: DesignTokens.space2), - Text( - '下拉查看更多工具', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, - fontWeight: FontWeight.w400, - ), - ), - const Spacer(), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - ), - child: GlassSearchBar( - readOnly: true, - onTap: () { - Get.toNamed(AppRoutes.search); - }, - ), - ), - const SizedBox(height: DesignTokens.space2), - _buildToolsBar(isDark), - const SizedBox(height: DesignTokens.space3), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - ), - child: GlassSegmentedControl( - segments: const [ - GlassSegment(label: '🔥 热门'), - GlassSegment(label: '🎲 今天吃什么'), - GlassSegment(label: '⭐ 推荐'), - ], - selectedIndex: _segmentIndex, - onChanged: (i) { - setState(() => _segmentIndex = i); - }, - ), - ), - const SizedBox(height: DesignTokens.space3), - Expanded( - child: NotificationListener( - onNotification: _handleScrollNotification, - child: CustomScrollView( - controller: _scrollController, - physics: const ClampingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - slivers: [ - SliverToBoxAdapter(child: _buildPullHint(isDark)), - SliverToBoxAdapter(child: _buildSegmentContent(isDark)), - ], - ), - ), - ), - ], - ), - _buildToolsPanel(isDark), - ], - ), - ), - ); - } - - Widget _buildToolsBar(bool isDark) { - if (_toolsController == null) return const SizedBox.shrink(); - - return Obx(() { - final tools = _toolsController!.frequentTools; - if (tools.isEmpty) { - return const SizedBox.shrink(); - } - return Container( - height: 90, - margin: const EdgeInsets.only(bottom: DesignTokens.space2), - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), - itemCount: tools.length + 1, - separatorBuilder: (context, index) => - const SizedBox(width: DesignTokens.space2), - itemBuilder: (context, index) { - if (index == tools.length) { - return _buildMoreToolsCard(isDark); - } - return _buildToolShortcut(tools[index], isDark); - }, - ), - ); - }); - } - - Widget _buildToolShortcut(ToolItem tool, bool isDark) { - return GestureDetector( - onTap: () => _navigateToTool(tool), - child: ClipRRect( - borderRadius: DesignTokens.borderRadiusMd, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: Container( - width: 72, - padding: const EdgeInsets.symmetric(vertical: DesignTokens.space2), - decoration: BoxDecoration( - color: isDark - ? DarkDesignTokens.glass - : DesignTokens.card.withValues(alpha: 0.75), - borderRadius: DesignTokens.borderRadiusMd, - border: Border.all( - color: isDark - ? DarkDesignTokens.glassBorder - : DesignTokens.text3.withValues(alpha: 0.12), - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Stack( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: DesignTokens.dynamicPrimary.withValues( - alpha: 0.1, - ), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Center( - child: Text(tool.icon, style: TextStyle(fontSize: 24)), - ), - ), - Positioned( - top: 0, - right: 0, - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: tool.needsNetwork - ? DesignTokens.green - : DesignTokens.dynamicPrimary, - shape: BoxShape.circle, - ), - ), - ), - ], - ), - const SizedBox(height: 6), - Text( - tool.name, - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ), - ), - ); - } - - Widget _buildMoreToolsCard(bool isDark) { - return GestureDetector( - onTap: _openPanel, - child: ClipRRect( - borderRadius: DesignTokens.borderRadiusMd, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: Container( - width: 72, - padding: EdgeInsets.symmetric(vertical: DesignTokens.space2), - decoration: BoxDecoration( - color: isDark - ? DesignTokens.dynamicPrimary.withValues(alpha: 0.08) - : DesignTokens.primaryLight.withValues(alpha: 0.7), - borderRadius: DesignTokens.borderRadiusMd, - border: Border.all( - color: DesignTokens.dynamicPrimary.withValues(alpha: 0.25), - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: DesignTokens.dynamicPrimary.withValues(alpha: 0.15), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Center( - child: Text('🛠️', style: TextStyle(fontSize: 24)), - ), - ), - SizedBox(height: 6), - Text( - '更多', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: DesignTokens.dynamicPrimary, - ), - ), - ], - ), - ), - ), - ), - ); - } - - void _navigateToTool(ToolItem tool) { - _toolsController?.recordUsage(tool.id); - if (tool.route.isNotEmpty) { - Get.toNamed(tool.route); - } - } - - Widget _buildPullHint(bool isDark) { - final showHint = _pullOffset > 0 && !_isPanelOpen; - if (!showHint) return const SizedBox.shrink(); - - final progress = (_pullOffset / _pullThreshold).clamp(0.0, 1.0); - return Container( - height: 40 + _pullOffset * 0.3, - alignment: Alignment.center, - child: Opacity( - opacity: progress, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - CupertinoIcons.chevron_compact_up, - size: 18, - color: DesignTokens.dynamicPrimary.withValues(alpha: progress), - ), - const SizedBox(width: 4), - Text( - progress >= 1.0 ? '松开进入工具中心' : '下拉查看更多工具', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: DesignTokens.dynamicPrimary.withValues(alpha: progress), - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ); - } - - Widget _buildToolsPanel(bool isDark) { - final screenHeight = MediaQuery.of(context).size.height; - if (_panelHeight != screenHeight) { - _panelHeight = screenHeight; - } - - return AnimatedBuilder( - animation: _panelAnimation, - builder: (context, child) { - final value = _panelAnimation.value; - if (value <= 0 && !_isPanelOpen) return const SizedBox.shrink(); - - return Stack( - children: [ - GestureDetector( - onTap: _closePanel, - behavior: HitTestBehavior.opaque, - child: Container( - color: Colors.black.withValues(alpha: 0.5 * value), - ), - ), - Positioned( - top: 0, - left: 0, - right: 0, - bottom: 0, - child: Transform.translate( - offset: Offset(0, -_panelHeight * (1 - value)), - child: Container( - height: _panelHeight, - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - color: isDark - ? DarkDesignTokens.background - : DesignTokens.background, - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(DesignTokens.radiusLg), - bottomRight: Radius.circular(DesignTokens.radiusLg), - ), - ), - child: SafeArea( - top: false, - child: Column( - children: [ - _buildPanelDragHandle(isDark), - Expanded( - child: ListView( - physics: const BouncingScrollPhysics(), - padding: EdgeInsets.zero, - children: [ - _buildPanelBasicInfo(isDark), - _buildPanelFrequentTools(isDark), - _buildPanelAllTools(isDark), - _buildPanelBrowseHistory(isDark), - const SizedBox(height: DesignTokens.space4), - ], - ), - ), - _buildPanelBottomActions(isDark), - ], - ), - ), - ), - ), - ), - ], - ); - }, - ); - } - - Widget _buildPanelDragHandle(bool isDark) { - return GestureDetector( - onVerticalDragEnd: (details) { - if (details.primaryVelocity != null && details.primaryVelocity! > 300) { - _closePanel(); - } - }, - behavior: HitTestBehavior.translucent, - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: DesignTokens.space2), - child: Column( - children: [ - Container( - width: 36, - height: 5, - decoration: BoxDecoration( - color: isDark - ? DarkDesignTokens.text3.withValues(alpha: 0.4) - : DesignTokens.text3.withValues(alpha: 0.25), - borderRadius: BorderRadius.circular(3), - ), - ), - const SizedBox(height: DesignTokens.space1), - Text( - '下滑关闭', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3.withValues(alpha: 0.5), - ), - ), - ], - ), - ), - ); - } - - Widget _buildPanelBasicInfo(bool isDark) { - return Padding( - padding: const EdgeInsets.fromLTRB( - DesignTokens.space4, - DesignTokens.space2, - DesignTokens.space4, - DesignTokens.space3, - ), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - DesignTokens.dynamicPrimary.withValues(alpha: 0.15), - DesignTokens.secondary.withValues(alpha: 0.1), - ], - ), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Center(child: Text('🛠️', style: TextStyle(fontSize: 24))), - ), - const SizedBox(width: DesignTokens.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '工具中心', - style: TextStyle( - fontSize: DesignTokens.fontXxl, - fontWeight: FontWeight.w700, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - Text( - '发现更多烹饪好帮手', - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, - ), - ), - ], - ), - ), - Obx(() { - if (_toolsController == null) return const SizedBox.shrink(); - final count = _toolsController!.tools.length; - return Container( - padding: EdgeInsets.symmetric( - horizontal: DesignTokens.space2 + 2, - vertical: DesignTokens.space1 + 1, - ), - decoration: BoxDecoration( - color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), - borderRadius: DesignTokens.borderRadiusSm, - ), - child: Text( - '$count 个工具', - style: TextStyle( - fontSize: DesignTokens.fontXs, - fontWeight: FontWeight.w600, - color: DesignTokens.dynamicPrimary, - ), - ), - ); - }), - ], - ), - ); - } - - Widget _buildPanelFrequentTools(bool isDark) { - if (_toolsController == null) return const SizedBox.shrink(); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '常用工具', - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w700, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - GestureDetector( - onTap: () { - _closePanel(); - Get.toNamed('/tools-center'); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '更多', - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: DesignTokens.dynamicPrimary, - fontWeight: FontWeight.w500, - ), - ), - Icon( - CupertinoIcons.chevron_right, - size: 14, - color: DesignTokens.dynamicPrimary, - ), - ], - ), - ), - ], - ), - const SizedBox(height: DesignTokens.space3), - Obx(() { - final frequent = _toolsController!.frequentTools; - if (frequent.isEmpty) return const SizedBox.shrink(); - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - crossAxisSpacing: DesignTokens.space3, - mainAxisSpacing: DesignTokens.space3, - childAspectRatio: 0.85, - ), - itemCount: frequent.length.clamp(0, 8), - itemBuilder: (context, index) { - final tool = frequent[index]; - return _buildToolGridItem(tool, isDark); - }, - ); - }), - ], - ), - ); - } - - Widget _buildToolGridItem(ToolItem tool, bool isDark) { - return GestureDetector( - onTap: () { - _closePanel(); - _navigateToTool(tool); - }, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - DesignTokens.dynamicPrimary.withValues(alpha: 0.12), - DesignTokens.secondary.withValues(alpha: 0.06), - ], - ), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Center(child: Text(tool.icon, fontSize: 24)), - ), - const SizedBox(height: DesignTokens.space1 + 2), - Text( - tool.name, - style: TextStyle( - fontSize: DesignTokens.fontXs, - fontWeight: FontWeight.w500, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ), - if (tool.usageCount > 0) - Text( - '${tool.usageCount}次', - style: TextStyle( - fontSize: 10, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3.withValues(alpha: 0.6), - ), - ), - ], - ), - ); - } - - Widget _buildPanelAllTools(bool isDark) { - if (_toolsController == null) return const SizedBox.shrink(); - - return Padding( - padding: const EdgeInsets.only( - left: DesignTokens.space4, - right: DesignTokens.space4, - top: DesignTokens.space4, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '所有工具', - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w700, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - const SizedBox(height: DesignTokens.space3), - Obx(() { - final tools = _toolsController!.tools; - final groups = >{}; - for (final t in tools) { - groups.putIfAbsent(t.category, () => []).add(t); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: groups.entries.map((entry) { - final category = entry.key; - final items = entry.value; - final info = _getCategoryStyle(category); - return Padding( - padding: const EdgeInsets.only(bottom: DesignTokens.space3), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text(info['icon'], style: TextStyle(fontSize: 16)), - const SizedBox(width: DesignTokens.space2), - Text( - info['name'], - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, - ), - ), - const SizedBox(width: DesignTokens.space2), - Text( - '${items.length}个', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, - ), - ), - ], - ), - const SizedBox(height: DesignTokens.space2), - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - crossAxisSpacing: DesignTokens.space3, - mainAxisSpacing: DesignTokens.space3, - childAspectRatio: 0.85, - ), - itemCount: items.length, - itemBuilder: (context, index) => - _buildToolGridItem(items[index], isDark), - ), - ], - ), - ); - }).toList(), - ); - }), - ], - ), - ); - } - - Map _getCategoryStyle(String category) { - const map = { - 'cooking': {'name': '烹饪助手', 'icon': '🍳'}, - 'health': {'name': '健康营养', 'icon': '💊'}, - 'data': {'name': '数据查询', 'icon': '📊'}, - 'planning': {'name': '规划管理', 'icon': '📅'}, - }; - return map[category] ?? {'name': category, 'icon': '📦'}; - } - - Widget _buildPanelBrowseHistory(bool isDark) { - return Padding( - padding: const EdgeInsets.only( - left: DesignTokens.space4, - right: DesignTokens.space4, - top: DesignTokens.space4, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '浏览记录', - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w700, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - GestureDetector( - onTap: () => Get.toNamed('/favorites'), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '更多', - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: DesignTokens.dynamicPrimary, - fontWeight: FontWeight.w500, - ), - ), - Icon( - CupertinoIcons.chevron_right, - size: 14, - color: DesignTokens.dynamicPrimary, - ), - ], - ), - ), - ], - ), - const SizedBox(height: DesignTokens.space3), - SizedBox( - height: 120, - child: FutureBuilder>>( - future: _loadBrowseHistory(), - builder: (context, snapshot) { - if (!snapshot.hasData || snapshot.data!.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - CupertinoIcons.clock, - size: 32, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3.withValues(alpha: 0.4), - ), - const SizedBox(height: DesignTokens.space2), - Text( - '暂无浏览记录', - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3.withValues(alpha: 0.5), - ), - ), - ], - ), - ); - } - final history = snapshot.data!; - return ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space1, - ), - separatorBuilder: (_, __) => - const SizedBox(width: DesignTokens.space2), - itemCount: history.length, - itemBuilder: (context, index) { - final item = history[index]; - return GestureDetector( - onTap: () { - _closePanel(); - Get.toNamed( - '/recipe-detail', - arguments: '${item['id']}', - ); - }, - child: Container( - width: 140, - decoration: BoxDecoration( - color: isDark - ? DarkDesignTokens.card - : DesignTokens.card.withValues(alpha: 0.6), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Column( - children: [ - Expanded( - flex: 3, - child: ClipRRect( - borderRadius: BorderRadius.vertical( - top: DesignTokens.radiusMd, - ), - child: Container( - width: double.infinity, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - DesignTokens.dynamicPrimary.withValues( - alpha: 0.15, - ), - DesignTokens.secondary.withValues( - alpha: 0.08, - ), - ], - ), - ), - child: Center( - child: Text('🍽️', fontSize: 28), - ), - ), - ), - ), - Expanded( - flex: 2, - child: Padding( - padding: const EdgeInsets.all( - DesignTokens.space2, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - item['name'] ?? '', - style: TextStyle( - fontSize: DesignTokens.fontXs, - fontWeight: FontWeight.w600, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - item['time'] ?? '', - style: TextStyle( - fontSize: 10, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3.withValues( - alpha: 0.6, - ), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); - }, - ); - }, - ), - ), - ], - ), - ); - } - - Future>> _loadBrowseHistory() async { - try { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getStringList('browse_history') ?? []; - return raw.map((e) { - final parts = e.split('|'); - return { - 'id': parts.length > 0 ? parts[0] : '', - 'name': parts.length > 1 ? parts[1] : '', - 'time': parts.length > 2 ? parts[2] : '', - }; - }).toList(); - } catch (_) { - return []; - } - } - - Widget _buildPanelBottomActions(bool isDark) { - return Container( - padding: EdgeInsets.fromLTRB( - DesignTokens.space4, - DesignTokens.space3, - DesignTokens.space4, - DesignTokens.space4 + MediaQuery.of(context).padding.bottom, - ), - decoration: BoxDecoration( - border: Border( - top: BorderSide( - color: isDark - ? DarkDesignTokens.glassBorder - : DesignTokens.text3.withValues(alpha: 0.08), - ), - ), - ), - child: SafeArea( - top: false, - child: Row( - children: [ - _buildBottomActionItem( - icon: CupertinoIcons.house, - label: '首页', - onTap: () { - _closePanel(); - Get.offAllNamed('/home'); - }, - isDark: isDark, - ), - _buildBottomActionItem( - icon: CupertinoIcons.heart, - label: '收藏', - onTap: () { - _closePanel(); - Get.toNamed('/favorites'); - }, - isDark: isDark, - ), - _buildBottomActionItem( - icon: CupertinoIcons.gear_alt, - label: '设置', - onTap: () { - _closePanel(); - Get.toNamed('/settings'); - }, - isDark: isDark, - ), - _buildBottomActionItem( - icon: CupertinoIcons.info_circle, - label: '关于', - onTap: () { - _closePanel(); - Get.toNamed('/about'); - }, - isDark: isDark, - ), - ], - ), - ), - ); - } - - Widget _buildBottomActionItem({ - required IconData icon, - required String label, - required VoidCallback onTap, - required bool isDark, - }) { - return Expanded( - child: GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.opaque, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: isDark - ? DarkDesignTokens.glass - : DesignTokens.text3.withValues(alpha: 0.06), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Icon( - icon, - size: 20, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - const SizedBox(height: DesignTokens.space1), - Text( - label, - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - ], - ), - ), - ); - } - - Widget _buildSegmentContent(bool isDark) { - switch (_segmentIndex) { - case 0: - return _buildHotSection(isDark); - case 1: - return _buildWhatToEatSection(isDark); - case 2: - return _buildRecommendSection(isDark); - default: - return const SizedBox.shrink(); - } - } - - Widget _buildHotSection(bool isDark) { - return Obx(() { - final List hotList = _hotController.hotList; - final isLoading = _hotController.isLoading.value; - - if (isLoading) { - return const SizedBox( - height: 300, - child: Center(child: CupertinoActivityIndicator()), - ); - } - - if (hotList.isEmpty) { - return SizedBox( - height: 300, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - CupertinoIcons.flame, - size: 48, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, - ), - const SizedBox(height: DesignTokens.space3), - Text( - '暂无热门数据', - style: TextStyle( - fontSize: DesignTokens.fontLg, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - ], - ), - ), - ); - } - - return SizedBox( - height: MediaQuery.of(context).size.height * 0.55, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - ), - child: GlassSegmentedControl( - segments: HotController.periodNames - .map((name) => GlassSegment(label: name)) - .toList(), - selectedIndex: _hotController.currentPeriod.value.index, - onChanged: (i) { - _hotController.switchPeriod(HotPeriod.values[i]); - }, - ), - ), - const SizedBox(height: DesignTokens.space3), - Expanded( - child: ListView.builder( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - ), - itemCount: hotList.length, - itemBuilder: (context, index) { - final recipe = hotList[index]; - return Padding( - padding: const EdgeInsets.only( - bottom: DesignTokens.space2 + 2, - ), - child: Dismissible( - key: ValueKey('hot_${recipe.id}_$index'), - direction: DismissDirection.horizontal, - confirmDismiss: (direction) async { - if (direction == DismissDirection.endToStart) { - Get.toNamed( - '/recipe-detail', - arguments: '${recipe.id}', - ); - return false; - } else { - return true; - } - }, - onDismissed: (direction) { - if (direction == DismissDirection.startToEnd) { - _showQuickActions(recipe, isDark); - } - }, - background: Container( - alignment: Alignment.centerLeft, - padding: EdgeInsets.only(left: 20), - decoration: BoxDecoration( - color: DesignTokens.dynamicPrimary, - borderRadius: DesignTokens.borderRadiusLg, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - CupertinoIcons.heart_fill, - color: CupertinoColors.white, - ), - const SizedBox(width: 8), - Text( - '收藏', - style: TextStyle( - color: CupertinoColors.white, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - secondaryBackground: Container( - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 20), - decoration: BoxDecoration( - color: DesignTokens.green, - borderRadius: DesignTokens.borderRadiusLg, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Text( - '查看详情', - style: TextStyle( - color: CupertinoColors.white, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: 8), - Icon( - CupertinoIcons.eye, - color: CupertinoColors.white, - ), - ], - ), - ), - child: GestureDetector( - onTap: () { - Get.toNamed( - '/recipe-detail', - arguments: '${recipe.id}', - ); - }, - child: Container( - padding: const EdgeInsets.all(DesignTokens.space3), - decoration: BoxDecoration( - color: isDark - ? DarkDesignTokens.card - : DesignTokens.card, - borderRadius: DesignTokens.borderRadiusLg, - boxShadow: DesignTokens.shadowsSm, - ), - child: Row( - children: [ - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: index < 3 - ? DesignTokens.orange.withValues( - alpha: 0.15, - ) - : DesignTokens.text3.withValues( - alpha: 0.1, - ), - borderRadius: DesignTokens.borderRadiusSm, - ), - child: Center( - child: Text( - '${index + 1}', - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w700, - color: index < 3 - ? DesignTokens.orange - : (isDark - ? DarkDesignTokens.text2 - : DesignTokens.text2), - ), - ), - ), - ), - const SizedBox(width: DesignTokens.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - recipe.name, - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w500, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, - ), - ), - const SizedBox(height: 2), - Text( - '${_hotController.sortByName}: ${recipe.count}', - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark - ? DarkDesignTokens.text2 - : DesignTokens.text2, - ), - ), - ], - ), - ), - Icon( - CupertinoIcons.chevron_forward, - size: 16, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, - ), - ], - ), - ), - ), - ), - ); - }, - ), - ), - ], - ), - ); - }); - } - - Widget _buildWhatToEatSection(bool isDark) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 100, - height: 100, - decoration: BoxDecoration( - color: DesignTokens.primaryLight, - borderRadius: DesignTokens.borderRadiusXl, - ), - child: Icon( - CupertinoIcons.shuffle, - size: 44, - color: DesignTokens.dynamicPrimary, - ), - ), - const SizedBox(height: DesignTokens.space4), - Text( - '不知道吃什么?', - style: TextStyle( - fontSize: DesignTokens.fontXl, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - const SizedBox(height: DesignTokens.space2), - Text( - '让小妈厨房帮你决定', - style: TextStyle( - fontSize: DesignTokens.fontMd, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - const SizedBox(height: DesignTokens.space6), - SizedBox( - width: 200, - child: CupertinoButton.filled( - borderRadius: DesignTokens.borderRadiusLg, - onPressed: () { - Get.toNamed('/what-to-eat'); - }, - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(CupertinoIcons.shuffle, size: 20), - SizedBox(width: DesignTokens.space2), - Text('随机推荐'), - ], - ), - ), - ), - const SizedBox(height: DesignTokens.space3), - SizedBox( - width: 200, - child: CupertinoButton( - borderRadius: DesignTokens.borderRadiusLg, - color: DesignTokens.primaryLight, - onPressed: () { - Get.toNamed('/what-to-eat'); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - CupertinoIcons.lightbulb, - size: 20, - color: DesignTokens.dynamicPrimary, - ), - const SizedBox(width: DesignTokens.space2), - Text( - '浏览推荐', - style: TextStyle(color: DesignTokens.dynamicPrimary), - ), - ], - ), - ), - ), - ], - ), - ); - } - - Widget _buildRecommendSection(bool isDark) { - if (_isLoadingCategories) { - return const SizedBox( - height: 300, - child: Center(child: CupertinoActivityIndicator()), - ); - } - - return SizedBox( - height: MediaQuery.of(context).size.height * 0.55, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - ), - child: GlassSegmentedControl( - segments: const [ - GlassSegment(label: '🍳 菜系'), - GlassSegment(label: '🥬 食材'), - GlassSegment(label: '🏷️ 标签'), - ], - selectedIndex: _recommendSubIndex, - onChanged: (i) { - setState(() => _recommendSubIndex = i); - }, - ), - ), - const SizedBox(height: DesignTokens.space3), - Expanded( - child: _recommendSubIndex == 0 - ? _buildCategoryGrid(_topCategories, isDark, 'category') - : _recommendSubIndex == 1 - ? _buildCategoryGrid( - _ingredientCategories, - isDark, - 'ingredient', - ) - : _buildTagContent(isDark), - ), - ], - ), - ); - } - - Widget _buildCategoryGrid( - List categories, - bool isDark, - String type, - ) { - if (categories.isEmpty) { - return Center( - child: Text( - '暂无数据', - style: TextStyle( - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - ); - } - - return GridView.builder( - padding: const EdgeInsets.all(DesignTokens.space4), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - mainAxisSpacing: DesignTokens.space3, - crossAxisSpacing: DesignTokens.space3, - childAspectRatio: 0.75, - ), - itemCount: categories.length, - itemBuilder: (context, index) { - final category = categories[index]; - return GestureDetector( - onTap: () { - Get.toNamed( - '/category-browse', - arguments: { - 'category': category, - 'title': category.name, - 'isIngredient': type == 'ingredient', - 'loadRecipesDirectly': true, - }, - ); - }, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: DesignTokens.dynamicPrimary.withValues(alpha: 0.12), - borderRadius: DesignTokens.borderRadiusLg, - ), - child: Center( - child: Text( - category.icon ?? '🍽️', - style: const TextStyle(fontSize: 28), - ), - ), - ), - const SizedBox(height: DesignTokens.space2), - Text( - category.name, - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ), - ], - ), - ); - }, - ); - } - - Widget _buildTagContent(bool isDark) { - final tags = _recommendTypeIndex == 0 ? _tasteTags : _cookingTags; - if (tags.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - CupertinoIcons.tag, - size: 48, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, - ), - const SizedBox(height: DesignTokens.space3), - Text( - '暂无标签数据', - style: TextStyle( - fontSize: DesignTokens.fontMd, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - ], - ), - ); - } - - return Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), - child: GlassSegmentedControl( - segments: [ - GlassSegment(label: '👅 口味'), - GlassSegment(label: '🔥 工艺'), - ], - selectedIndex: _recommendTypeIndex, - onChanged: (i) { - setState(() => _recommendTypeIndex = i); - }, - ), - ), - const SizedBox(height: DesignTokens.space3), - Expanded( - child: ListView.separated( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - vertical: DesignTokens.space1, - ), - itemCount: tags.length, - separatorBuilder: (context, index) => - const SizedBox(height: DesignTokens.space2), - itemBuilder: (context, index) { - final tag = tags[index]; - return GestureDetector( - onTap: () { - Get.toNamed( - '/tag-recipe-list', - arguments: { - 'tagName': tag.name, - 'tagId': tag.id, - 'tagType': _recommendTypeIndex == 0 ? 'taste' : 'process', - }, - ); - }, - child: ClipRRect( - borderRadius: DesignTokens.borderRadiusMd, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space3, - vertical: DesignTokens.space3, - ), - decoration: BoxDecoration( - color: isDark - ? DarkDesignTokens.glass - : DesignTokens.card.withValues(alpha: 0.6), - borderRadius: DesignTokens.borderRadiusMd, - border: Border.all( - color: isDark - ? DarkDesignTokens.glassBorder - : DesignTokens.text3.withValues(alpha: 0.08), - ), - ), - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: DesignTokens.dynamicPrimary.withValues( - alpha: 0.1, - ), - borderRadius: DesignTokens.borderRadiusSm, - ), - child: Center( - child: Text( - '🏷️', - style: TextStyle(fontSize: 18), - ), - ), - ), - const SizedBox(width: DesignTokens.space3), - Expanded( - child: Text( - tag.name, - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w500, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, - ), - ), - ), - if (tag.count != null && tag.count! > 0) - Text( - '${tag.count}', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, - ), - ), - const SizedBox(width: DesignTokens.space2), - Icon( - CupertinoIcons.chevron_right, - size: 14, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, - ), - ], - ), - ), - ), - ), - ); - }, - ), - ), - ], - ); - } - - void _showQuickActions(repo.HotItem recipe, bool isDark) { - showCupertinoModalPopup( - context: context, - builder: (BuildContext context) => CupertinoActionSheet( - title: Text( - recipe.name, - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w600, - ), - ), - message: Text( - '浏览量: ${recipe.count}', - style: TextStyle( - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - actions: [ - CupertinoActionSheetAction( - onPressed: () { - Navigator.pop(context); - Get.toNamed('/recipe-detail', arguments: '${recipe.id}'); - }, - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(CupertinoIcons.eye, size: 20), - SizedBox(width: 8), - Text('查看详情'), - ], - ), - ), - CupertinoActionSheetAction( - onPressed: () { - Navigator.pop(context); - ToastService.show(message: '已添加到收藏'); - }, - isDefaultAction: true, - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(CupertinoIcons.heart, size: 20), - SizedBox(width: 8), - Text('添加收藏'), - ], - ), - ), - ], - cancelButton: CupertinoActionSheetAction( - onPressed: () => Navigator.pop(context), - isDestructiveAction: true, - child: const Text('取消'), - ), - ), - ); - } -} diff --git a/lib/src/pages/profile/about_page.dart b/lib/src/pages/profile/info/about_page.dart similarity index 100% rename from lib/src/pages/profile/about_page.dart rename to lib/src/pages/profile/info/about_page.dart diff --git a/lib/src/pages/profile/app_info_page.dart b/lib/src/pages/profile/info/app_info_page.dart similarity index 99% rename from lib/src/pages/profile/app_info_page.dart rename to lib/src/pages/profile/info/app_info_page.dart index 4a8f76a..07a64f9 100644 --- a/lib/src/pages/profile/app_info_page.dart +++ b/lib/src/pages/profile/info/app_info_page.dart @@ -3,7 +3,7 @@ * 名称: 软件信息页面 * 作用: 展示应用版本、技术栈、构建信息、设备信息等 * 创建: 2026-04-18 - * 更新: 2026-04-18 修复鸿蒙端设备信息显示unknown,渲染引擎动态检测 + * 更新: 2026-04-19 修复鸿蒙端操作系统显示unknown、设备类型显示未知,增加defaultTargetPlatform兜底 */ import 'dart:ui'; @@ -636,12 +636,12 @@ class _AppInfoPageState extends State { String platformName = platform.operatingSystemName; String deviceType = '未知设备'; - if (platform.isMobile) { + if (platform.isWeb) { + deviceType = '🌐 Web 浏览器'; + } else if (platform.isMobile) { deviceType = '📱 移动设备'; } else if (platform.isDesktop) { deviceType = '💻 桌面设备'; - } else if (platform.isWeb) { - deviceType = '🌐 Web 浏览器'; } final size = MediaQuery.of(context).size; diff --git a/lib/src/pages/profile/guide_page.dart b/lib/src/pages/profile/info/guide_page.dart similarity index 100% rename from lib/src/pages/profile/guide_page.dart rename to lib/src/pages/profile/info/guide_page.dart diff --git a/lib/src/pages/profile/learn_us_page.dart b/lib/src/pages/profile/info/learn_us_page.dart similarity index 100% rename from lib/src/pages/profile/learn_us_page.dart rename to lib/src/pages/profile/info/learn_us_page.dart diff --git a/lib/src/pages/profile/privacy_policy_page.dart b/lib/src/pages/profile/info/privacy_policy_page.dart similarity index 100% rename from lib/src/pages/profile/privacy_policy_page.dart rename to lib/src/pages/profile/info/privacy_policy_page.dart diff --git a/lib/src/pages/profile/references_page.dart b/lib/src/pages/profile/info/references_page.dart similarity index 100% rename from lib/src/pages/profile/references_page.dart rename to lib/src/pages/profile/info/references_page.dart diff --git a/lib/src/pages/profile/profile_home.dart b/lib/src/pages/profile/profile_home.dart index f30b2b9..6d75f63 100644 --- a/lib/src/pages/profile/profile_home.dart +++ b/lib/src/pages/profile/profile_home.dart @@ -1,4 +1,4 @@ -/* +/* * 文件: profile_home.dart * 名称: 个人中心首页标签 * 作用: iOS 26 风格的用户信息展示、功能入口和消息预览 @@ -8,6 +8,7 @@ import 'package:flutter/cupertino.dart'; import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/app_config.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; import 'package:mom_kitchen/src/controllers/user/profile_controller.dart'; import 'package:mom_kitchen/src/pages/profile/settings/personalization_page.dart'; @@ -279,16 +280,16 @@ class ProfileHomeTab extends StatelessWidget { AppRoutes.miniCard, ), _FeatureItem( - CupertinoIcons.cart, + CupertinoIcons.share, '分享记录', DesignTokens.green, - AppRoutes.shoppingList, + AppRoutes.shareRecords, ), _FeatureItem( CupertinoIcons.bookmark, '评分记录', DesignTokens.secondary, - AppRoutes.favorites, + AppRoutes.ratingRecords, ), ]; @@ -459,7 +460,7 @@ class ProfileHomeTab extends StatelessWidget { ), SizedBox(height: DesignTokens.space2), Text( - 'v0.88.5 · 2026 Liquid Glass', + 'v${AppConfig.appVersion} · 2026 Liquid Glass', style: TextStyle( fontSize: DesignTokens.fontXs, color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, diff --git a/lib/src/pages/profile/profile_settings.dart b/lib/src/pages/profile/profile_settings.dart index d2aebaa..62552bb 100644 --- a/lib/src/pages/profile/profile_settings.dart +++ b/lib/src/pages/profile/profile_settings.dart @@ -10,6 +10,7 @@ import 'package:flutter/cupertino.dart'; import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/app_config.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; import 'package:mom_kitchen/src/controllers/user/profile_controller.dart'; import 'package:mom_kitchen/src/pages/profile/settings/personalization_page.dart'; @@ -269,7 +270,7 @@ class ProfileSettingsTab extends StatelessWidget { ), SizedBox(height: DesignTokens.space2), Text( - 'v0.88.5 · iOS 26 Liquid Glass', + 'v${AppConfig.appVersion} · iOS 26 Liquid Glass', style: TextStyle( fontSize: DesignTokens.fontXs, color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, diff --git a/lib/src/pages/profile/social/share_records_page.dart b/lib/src/pages/profile/social/share_records_page.dart new file mode 100644 index 0000000..5f9b10e --- /dev/null +++ b/lib/src/pages/profile/social/share_records_page.dart @@ -0,0 +1,879 @@ +/* + * 文件: share_records_page.dart + * 名称: 分享记录页面 + * 作用: iOS 26 Liquid Glass 风格的分享记录管理页面 + * 创建: 2026-04-19 + * 更新: 2026-04-19 初始创建,支持搜索/筛选/删除/重新分享/查看菜谱 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart' show Colors; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/app_routes.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/controllers/data/share_record_controller.dart'; +import 'package:mom_kitchen/src/models/data/share_record_model.dart'; +import 'package:mom_kitchen/src/services/ui/toast_service.dart'; +import 'package:mom_kitchen/src/utils/app_utils.dart'; +import 'package:mom_kitchen/src/widgets/glass/glass_container.dart'; + +class ShareRecordsPage extends StatefulWidget { + const ShareRecordsPage({super.key}); + + @override + State createState() => _ShareRecordsPageState(); +} + +class _ShareRecordsPageState extends State { + ShareRecordController? _controller; + bool _isInitialized = false; + + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_isInitialized) { + _isInitialized = true; + _initController(); + } + } + + void _initController() { + try { + if (Get.isRegistered()) { + _controller = Get.find(); + } else { + _controller = Get.put(ShareRecordController()); + } + } catch (e) { + debugPrint('ShareRecordsPage: Controller init error: $e'); + } + } + + ShareRecordController get _safeController { + if (_controller == null) { + throw StateError('ShareRecordController is not initialized'); + } + return _controller!; + } + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + if (_controller == null) { + return CupertinoPageScaffold( + backgroundColor: + isDark ? DarkDesignTokens.background : DesignTokens.background, + child: const Center(child: CupertinoActivityIndicator()), + ); + } + + final controller = _safeController; + + return CupertinoPageScaffold( + backgroundColor: + isDark ? DarkDesignTokens.background : DesignTokens.background, + navigationBar: CupertinoNavigationBar( + middle: Text( + '🔗 分享记录', + style: TextStyle( + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + ), + ), + leading: CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () => Get.back(), + child: Icon( + CupertinoIcons.back, + color: DesignTokens.dynamicPrimary, + ), + ), + trailing: Obx( + () => controller.records.isNotEmpty + ? CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () => + _showClearDialog(context, controller, isDark), + child: Text( + '清空', + style: TextStyle( + color: DesignTokens.red, + fontSize: DesignTokens.fontMd, + ), + ), + ) + : const SizedBox.shrink(), + ), + backgroundColor: isDark + ? DarkDesignTokens.background.withValues(alpha: 0.9) + : DesignTokens.background.withValues(alpha: 0.9), + border: null, + ), + child: SafeArea( + child: Obx(() => _buildBody(context, controller, isDark)), + ), + ); + } + + Widget _buildBody( + BuildContext context, + ShareRecordController controller, + bool isDark, + ) { + final records = controller.records; + + if (records.isEmpty) { + return _buildEmptyState(isDark); + } + + return Column( + children: [ + _buildStatsHeader(controller, isDark), + _buildSearchBar(isDark, controller), + _buildFilterTabs(isDark, controller), + Expanded( + child: Obx(() { + final filtered = controller.filteredRecords; + if (filtered.isEmpty) { + return _buildNoResultsState(isDark); + } + return ListView.separated( + controller: _scrollController, + padding: const EdgeInsets.all(DesignTokens.space4), + itemCount: filtered.length, + separatorBuilder: (_, _) => + const SizedBox(height: DesignTokens.space3), + itemBuilder: (context, index) => + _buildRecordCard(context, controller, filtered[index], isDark), + ); + }), + ), + ], + ); + } + + Widget _buildEmptyState(bool isDark) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: DesignTokens.primaryLight, + borderRadius: DesignTokens.borderRadiusXl, + ), + child: Icon( + CupertinoIcons.share, + size: 48, + color: DesignTokens.dynamicPrimary, + ), + ), + const SizedBox(height: DesignTokens.space4), + Text( + '暂无分享记录', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space2), + Text( + '在菜谱详情页点击分享即可记录', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space4), + SizedBox( + width: 180, + child: CupertinoButton.filled( + borderRadius: DesignTokens.borderRadiusLg, + onPressed: () => Get.toNamed('/discover'), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.compass, size: 18), + SizedBox(width: DesignTokens.space2), + Text('去发现'), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildNoResultsState(bool isDark) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🔍', style: TextStyle(fontSize: 48)), + const SizedBox(height: DesignTokens.space3), + Text( + '没有找到匹配的记录', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space2), + CupertinoButton( + onPressed: () { + _searchController.clear(); + _safeController.clearSearch(); + _safeController.setFilterType(null); + }, + child: Text( + '清除筛选', + style: TextStyle(color: DesignTokens.dynamicPrimary), + ), + ), + ], + ), + ); + } + + Widget _buildStatsHeader(ShareRecordController controller, bool isDark) { + return Obx(() { + final total = controller.count; + return Container( + margin: const EdgeInsets.fromLTRB( + DesignTokens.space4, + DesignTokens.space4, + DesignTokens.space4, + 0, + ), + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.15), + ), + ), + child: Row( + children: [ + const Text('📊', style: TextStyle(fontSize: 20)), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Text( + '共 $total 条记录', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + if (controller.textShareCount > 0) + _buildStatBadge( + '📝 ${controller.textShareCount}', + DesignTokens.dynamicPrimary, + isDark, + ), + if (controller.linkShareCount > 0) ...[ + const SizedBox(width: DesignTokens.space1), + _buildStatBadge( + '🔗 ${controller.linkShareCount}', + DesignTokens.green, + isDark, + ), + ], + if (controller.qrShareCount > 0) ...[ + const SizedBox(width: DesignTokens.space1), + _buildStatBadge( + '📱 ${controller.qrShareCount}', + DesignTokens.purple, + isDark, + ), + ], + if (controller.emailShareCount > 0) ...[ + const SizedBox(width: DesignTokens.space1), + _buildStatBadge( + '📧 ${controller.emailShareCount}', + DesignTokens.secondary, + isDark, + ), + ], + ], + ), + ); + }); + } + + Widget _buildStatBadge(String text, Color color, bool isDark) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + text, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: color, + ), + ), + ); + } + + Widget _buildSearchBar(bool isDark, ShareRecordController controller) { + return Obx(() { + final isSearching = controller.searchQuery.isNotEmpty; + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + child: GlassContainer( + padding: + const EdgeInsets.symmetric(horizontal: DesignTokens.space3), + borderRadius: DesignTokens.radiusMd, + opacity: isDark ? 0.6 : 0.75, + child: Row( + children: [ + Icon( + CupertinoIcons.search, + size: 18, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: CupertinoTextField( + controller: _searchController, + focusNode: _searchFocusNode, + onChanged: (value) => controller.setSearchQuery(value), + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + placeholder: '搜索分享记录...', + placeholderStyle: TextStyle( + color: + isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + decoration: const BoxDecoration(), + padding: EdgeInsets.zero, + ), + ), + if (isSearching) + GestureDetector( + onTap: () { + _searchController.clear(); + controller.clearSearch(); + }, + child: Icon( + CupertinoIcons.clear_circled_solid, + size: 18, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ), + ); + }); + } + + Widget _buildFilterTabs(bool isDark, ShareRecordController controller) { + return Obx(() { + final currentType = controller.filterType; + final types = ShareType.values; + + return Container( + height: 40, + margin: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: types.length + 1, + separatorBuilder: (_, _) => + const SizedBox(width: DesignTokens.space2), + itemBuilder: (context, index) { + if (index == 0) { + final isSelected = currentType == null; + return _buildFilterChip( + '📋', + '全部', + isSelected, + isDark, + () => controller.setFilterType(null), + ); + } + + final type = types[index - 1]; + final isSelected = type == currentType; + return _buildFilterChip( + _getTypeIcon(type), + _getTypeLabel(type), + isSelected, + isDark, + () => controller.setFilterType(type), + ); + }, + ), + ); + }); + } + + String _getTypeIcon(ShareType type) { + switch (type) { + case ShareType.text: + return '📝'; + case ShareType.link: + return '🔗'; + case ShareType.qr: + return '📱'; + case ShareType.email: + return '📧'; + } + } + + String _getTypeLabel(ShareType type) { + switch (type) { + case ShareType.text: + return '文本'; + case ShareType.link: + return '链接'; + case ShareType.qr: + return '二维码'; + case ShareType.email: + return '邮件'; + } + } + + Widget _buildFilterChip( + String emoji, + String label, + bool isSelected, + bool isDark, + VoidCallback onTap, + ) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.12) + : (isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.6)), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.4) + : Colors.transparent, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(emoji, style: const TextStyle(fontSize: 14)), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark ? DarkDesignTokens.text1 : DesignTokens.text1), + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + + Widget _buildRecordCard( + BuildContext context, + ShareRecordController controller, + ShareRecordModel record, + bool isDark, + ) { + return Dismissible( + key: ValueKey(record.id), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: DesignTokens.space5), + decoration: BoxDecoration( + color: DesignTokens.red, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: const Icon(CupertinoIcons.delete, color: CupertinoColors.white), + ), + confirmDismiss: (_) => _confirmDelete(context, record, isDark), + onDismissed: (_) { + controller.removeRecord(record.id); + ToastService.show(message: '记录已删除 🗑️'); + }, + child: GestureDetector( + onTap: () => _showRecordDetail(context, record, isDark), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.15), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + '🍳 ${record.recipeTitle}', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space1, + vertical: 2, + ), + decoration: BoxDecoration( + color: _getTypeColor(record.shareType) + .withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '${record.typeIcon} ${record.typeLabel}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: _getTypeColor(record.shareType), + ), + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + Row( + children: [ + if (record.categoryName != null && + record.categoryName!.isNotEmpty) ...[ + Icon( + CupertinoIcons.folder, + size: 12, + color: + isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + const SizedBox(width: DesignTokens.space1), + Text( + record.categoryName!, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + const SizedBox(width: DesignTokens.space2), + ], + if (record.ratingScore != null) ...[ + const Text('⭐', style: TextStyle(fontSize: 12)), + const SizedBox(width: 2), + Text( + '${record.ratingScore}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + const SizedBox(width: DesignTokens.space2), + ], + if (record.viewCount != null && record.viewCount! > 0) ...[ + const Text('👁️', style: TextStyle(fontSize: 12)), + const SizedBox(width: 2), + Text( + '${record.viewCount}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + const SizedBox(width: DesignTokens.space2), + ], + if (record.likeCount != null && record.likeCount! > 0) ...[ + const Text('❤️', style: TextStyle(fontSize: 12)), + const SizedBox(width: 2), + Text( + '${record.likeCount}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ], + ], + ), + const SizedBox(height: DesignTokens.space1), + Row( + children: [ + Icon( + CupertinoIcons.clock, + size: 12, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + const SizedBox(width: DesignTokens.space1), + Text( + record.displayDate, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + if (record.shareUrl != null) ...[ + const SizedBox(width: DesignTokens.space2), + Icon( + CupertinoIcons.link, + size: 12, + color: + isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ], + ], + ), + ], + ), + ), + ), + ); + } + + Color _getTypeColor(ShareType type) { + switch (type) { + case ShareType.text: + return DesignTokens.dynamicPrimary; + case ShareType.link: + return DesignTokens.green; + case ShareType.qr: + return DesignTokens.purple; + case ShareType.email: + return DesignTokens.secondary; + } + } + + void _showRecordDetail( + BuildContext context, + ShareRecordModel record, + bool isDark, + ) { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: Text('${record.typeIcon} ${record.recipeTitle}'), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: DesignTokens.space3), + _detailRow('📤 类型', record.typeLabel, isDark), + if (record.categoryName != null) + _detailRow('📂 分类', record.categoryName!, isDark), + if (record.ratingScore != null) + _detailRow('⭐ 评分', '${record.ratingScore}', isDark), + if (record.viewCount != null) + _detailRow('👁️ 浏览', '${record.viewCount}', isDark), + if (record.likeCount != null) + _detailRow('❤️ 点赞', '${record.likeCount}', isDark), + if (record.shareUrl != null) + _detailRow('🔗 链接', record.shareUrl!, isDark), + _detailRow('🕐 时间', record.displayDate, isDark), + if (record.note != null && record.note!.isNotEmpty) + _detailRow('📝 备注', record.note!, isDark), + ], + ), + actions: [ + CupertinoDialogAction( + onPressed: () { + Navigator.pop(ctx); + _reshareRecord(record); + }, + child: Text( + '重新分享', + style: TextStyle(color: DesignTokens.dynamicPrimary), + ), + ), + CupertinoDialogAction( + onPressed: () { + Navigator.pop(ctx); + Get.toNamed( + AppRoutes.recipeDetail, + arguments: record.recipeId, + ); + }, + child: Text( + '查看菜谱', + style: TextStyle(color: DesignTokens.dynamicPrimary), + ), + ), + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('关闭'), + ), + ], + ), + ); + } + + void _reshareRecord(ShareRecordModel record) { + final shareText = StringBuffer(); + shareText.writeln('🍳 ${record.recipeTitle}'); + if (record.categoryName != null) { + shareText.writeln('📂 ${record.categoryName}'); + } + if (record.ratingScore != null) { + shareText.writeln('⭐ ${record.ratingScore}'); + } + if (record.shareUrl != null) { + shareText.writeln('🔗 ${record.shareUrl}'); + } + shareText.writeln(''); + shareText.write('— 来自 小妈厨房 App 🍳'); + AppUtils.shareContent(shareText.toString(), subject: '🍳 ${record.recipeTitle}'); + } + + Widget _detailRow(String label, String value, bool isDark) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space1), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 72, + child: Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + fontWeight: FontWeight.w500, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + Future _confirmDelete( + BuildContext context, + ShareRecordModel record, + bool isDark, + ) async { + return await showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('删除记录'), + content: Text('确定要删除「${record.recipeTitle}」的分享记录吗?'), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), + ), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () => Navigator.pop(ctx, true), + child: const Text('删除'), + ), + ], + ), + ) ?? + false; + } + + void _showClearDialog( + BuildContext context, + ShareRecordController controller, + bool isDark, + ) { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('清空分享记录'), + content: const Text('确定要清空所有分享记录吗?此操作不可撤销。'), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () { + controller.clearRecords(); + Navigator.pop(ctx); + ToastService.show(message: '分享记录已清空 🗑️'); + }, + child: const Text('清空'), + ), + ], + ), + ); + } +} diff --git a/lib/src/pages/profile/bedtime_reminder_page.dart b/lib/src/pages/profile/tools/bedtime_reminder_page.dart similarity index 100% rename from lib/src/pages/profile/bedtime_reminder_page.dart rename to lib/src/pages/profile/tools/bedtime_reminder_page.dart diff --git a/lib/src/pages/profile/data_export_page.dart b/lib/src/pages/profile/tools/data_export_page.dart similarity index 100% rename from lib/src/pages/profile/data_export_page.dart rename to lib/src/pages/profile/tools/data_export_page.dart diff --git a/lib/src/pages/profile/permission_page.dart b/lib/src/pages/profile/tools/permission_page.dart similarity index 100% rename from lib/src/pages/profile/permission_page.dart rename to lib/src/pages/profile/tools/permission_page.dart diff --git a/lib/src/pages/profile/tools/rating_records_page.dart b/lib/src/pages/profile/tools/rating_records_page.dart new file mode 100644 index 0000000..b8c7a74 --- /dev/null +++ b/lib/src/pages/profile/tools/rating_records_page.dart @@ -0,0 +1,1344 @@ +/* + * 文件: rating_records_page.dart + * 名称: 评分记录页面 + * 作用: iOS 26 Liquid Glass 风格的评分记录管理页面 + * 更新: 2026-04-19 初始创建:评分记录列表、搜索、筛选、排序、批量删除、统计 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/config/app_routes.dart'; +import 'package:mom_kitchen/src/controllers/data/rating_records_controller.dart'; +import 'package:mom_kitchen/src/models/data/rating_record_model.dart'; +import 'package:mom_kitchen/src/widgets/glass/glass_container.dart'; +import 'package:mom_kitchen/src/services/ui/toast_service.dart'; + +class RatingRecordsPage extends StatefulWidget { + const RatingRecordsPage({super.key}); + + @override + State createState() => _RatingRecordsPageState(); +} + +class _RatingRecordsPageState extends State + with SingleTickerProviderStateMixin { + RatingRecordsController? _controller; + bool _isInitialized = false; + + final ScrollController _scrollController = ScrollController(); + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + _scrollController.dispose(); + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_isInitialized) { + _isInitialized = true; + _initController(); + } + } + + void _initController() { + try { + if (Get.isRegistered()) { + _controller = Get.find(); + } else { + _controller = Get.put(RatingRecordsController(), permanent: true); + } + } catch (e) { + debugPrint('RatingRecordsPage: Controller init error: $e'); + } + } + + RatingRecordsController get _safeController { + if (_controller == null) { + throw StateError('RatingRecordsController is not initialized'); + } + return _controller!; + } + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + if (_controller == null) { + return CupertinoPageScaffold( + backgroundColor: + isDark ? DarkDesignTokens.background : DesignTokens.background, + child: const Center(child: CupertinoActivityIndicator()), + ); + } + + final controller = _safeController; + + return CupertinoPageScaffold( + backgroundColor: + isDark ? DarkDesignTokens.background : DesignTokens.background, + child: SafeArea( + child: Stack( + children: [ + CustomScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: [ + SliverToBoxAdapter(child: _buildHeader(isDark, controller)), + SliverToBoxAdapter( + child: _buildSearchBar(isDark, controller)), + SliverToBoxAdapter( + child: _buildStatisticsBar(isDark, controller)), + SliverToBoxAdapter( + child: _buildFilterAndSort(isDark, controller)), + Obx(() { + final records = controller.records; + if (records.isEmpty) { + return SliverToBoxAdapter( + child: SizedBox( + height: 300, + child: _buildEmptyState(isDark), + ), + ); + } + return _buildRecordsSliverList(records, isDark, controller); + }), + SliverToBoxAdapter( + child: _ScrollEndIndicator( + scrollController: _scrollController, + isDark: isDark, + ), + ), + SliverToBoxAdapter( + child: Obx( + () => controller.isEditMode.value + ? const SizedBox(height: 100) + : const SizedBox.shrink(), + ), + ), + ], + ), + Obx( + () => controller.isEditMode.value + ? _buildFloatingSideBar(isDark, controller) + : const SizedBox.shrink(), + ), + ], + ), + ), + ); + } + + // ─── Header ─── + + Widget _buildHeader(bool isDark, RatingRecordsController controller) { + return Obx(() { + final count = controller.count; + final isEditMode = controller.isEditMode.value; + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '⭐ 评分记录', + style: TextStyle( + fontSize: DesignTokens.fontXl, + fontWeight: FontWeight.bold, + color: + isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + if (count > 0) + Text( + '$count 条评分记录', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + if (count > 0) ...[ + const SizedBox(width: DesignTokens.space2), + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: + DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: + BorderRadius.circular(DesignTokens.radiusFull), + ), + child: Text( + '$count', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: DesignTokens.dynamicPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + const Spacer(), + if (count > 0) + GestureDetector( + onTap: () => controller.toggleEditMode(), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: isEditMode + ? DesignTokens.dynamicPrimary + .withValues(alpha: 0.15) + : (isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.7)), + borderRadius: + BorderRadius.circular(DesignTokens.radiusFull), + border: Border.all( + color: isEditMode + ? DesignTokens.dynamicPrimary + .withValues(alpha: 0.5) + : Colors.transparent, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isEditMode + ? CupertinoIcons.xmark + : CupertinoIcons.pencil, + size: 14, + color: isEditMode + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + const SizedBox(width: 4), + Text( + isEditMode ? '取消' : '管理', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: isEditMode + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1), + ), + ), + ], + ), + ), + ), + ], + ), + ); + }); + } + + // ─── Search Bar ─── + + Widget _buildSearchBar(bool isDark, RatingRecordsController controller) { + return Obx(() { + final isSearching = controller.isSearching; + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + child: GlassContainer( + padding: + const EdgeInsets.symmetric(horizontal: DesignTokens.space3), + borderRadius: DesignTokens.radiusMd, + opacity: isDark ? 0.6 : 0.75, + child: Row( + children: [ + Icon( + CupertinoIcons.search, + size: 18, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: CupertinoTextField( + controller: _searchController, + focusNode: _searchFocusNode, + onChanged: (value) => controller.setSearchQuery(value), + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: + isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + placeholder: '搜索评分记录...', + placeholderStyle: TextStyle( + color: + isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + decoration: const BoxDecoration(), + padding: EdgeInsets.zero, + ), + ), + if (isSearching) + GestureDetector( + onTap: () { + _searchController.clear(); + controller.clearSearch(); + }, + child: Icon( + CupertinoIcons.clear_circled_solid, + size: 18, + color: + isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ), + ); + }); + } + + // ─── Statistics Bar ─── + + Widget _buildStatisticsBar( + bool isDark, RatingRecordsController controller) { + return Obx(() { + final stats = controller.statistics; + final total = stats['total'] as int? ?? 0; + if (total == 0) return const SizedBox.shrink(); + + final avgScore = stats['avgScore'] as String? ?? '0.0'; + + return Container( + height: 32, + margin: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space1, + ), + child: Row( + children: [ + _buildStatChip('📋', '共 $total 条', isDark), + const SizedBox(width: DesignTokens.space2), + _buildStatChip('⭐', '均分 $avgScore', isDark), + if ((stats['5'] as int? ?? 0) > 0) ...[ + const SizedBox(width: DesignTokens.space2), + _buildStatChip('🌟', '${stats['5']}', isDark), + ], + if ((stats['4'] as int? ?? 0) > 0) ...[ + const SizedBox(width: DesignTokens.space2), + _buildStatChip('⭐', '${stats['4']}', isDark), + ], + const Spacer(), + _buildExportButton(isDark, controller), + ], + ), + ); + }); + } + + Widget _buildStatChip(String icon, String label, bool isDark) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(DesignTokens.radiusSm), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(icon, style: const TextStyle(fontSize: 12)), + const SizedBox(width: 2), + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ); + } + + Widget _buildExportButton( + bool isDark, RatingRecordsController controller) { + return GestureDetector( + onTap: () => _showExportSheet(isDark, controller), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(DesignTokens.radiusSm), + border: Border.all( + color: + DesignTokens.dynamicPrimary.withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.square_arrow_up, + size: 12, + color: DesignTokens.dynamicPrimary, + ), + const SizedBox(width: 2), + Text( + '导出', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + + // ─── Filter & Sort ─── + + Widget _buildFilterAndSort( + bool isDark, RatingRecordsController controller) { + return Obx(() { + final filterScore = controller.filterScore.value; + + return Container( + height: 40, + margin: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), + child: Row( + children: [ + Expanded( + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: 6, + separatorBuilder: (_, _) => + const SizedBox(width: DesignTokens.space2), + itemBuilder: (context, index) { + if (index == 0) { + final isSelected = filterScore == 0; + return GestureDetector( + onTap: () => controller.clearFilter(), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary + .withValues(alpha: 0.12) + : (isDark + ? DarkDesignTokens.glass + : DesignTokens.card + .withValues(alpha: 0.6)), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary + .withValues(alpha: 0.4) + : Colors.transparent, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('📋', + style: TextStyle(fontSize: 14)), + const SizedBox(width: 4), + Text( + '全部', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1), + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + + final score = 6 - index; + final isSelected = filterScore == score; + final emoji = _getScoreFilterEmoji(score); + + return GestureDetector( + onTap: () => controller.setFilterScore(score), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary + .withValues(alpha: 0.12) + : (isDark + ? DarkDesignTokens.glass + : DesignTokens.card + .withValues(alpha: 0.6)), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary + .withValues(alpha: 0.4) + : Colors.transparent, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(emoji, style: const TextStyle(fontSize: 14)), + const SizedBox(width: 4), + Text( + '$score分', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1), + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + ], + ), + ), + ); + }, + ), + ), + const SizedBox(width: DesignTokens.space2), + _buildSortButton(isDark, controller), + ], + ), + ); + }); + } + + String _getScoreFilterEmoji(int score) { + switch (score) { + case 5: + return '🌟'; + case 4: + return '⭐'; + case 3: + return '👍'; + case 2: + return '😐'; + case 1: + return '👎'; + default: + return '⭐'; + } + } + + Widget _buildSortButton( + bool isDark, RatingRecordsController controller) { + return Obx(() { + final sortMode = controller.sortMode.value; + final sortLabel = _getSortLabel(sortMode); + + return GestureDetector( + onTap: () => _showSortOptions(isDark, controller), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: + DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: DesignTokens.dynamicPrimary + .withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.arrow_up_arrow_down, + size: 14, + color: DesignTokens.dynamicPrimary, + ), + const SizedBox(width: 4), + Text( + sortLabel, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + }); + } + + String _getSortLabel(RatingSortMode mode) { + switch (mode) { + case RatingSortMode.newest: + return '最新'; + case RatingSortMode.oldest: + return '最早'; + case RatingSortMode.scoreHigh: + return '高分'; + case RatingSortMode.scoreLow: + return '低分'; + } + } + + // ─── Records List ─── + + Widget _buildRecordsSliverList( + List records, + bool isDark, + RatingRecordsController controller, + ) { + return SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final record = records[index]; + return Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space2), + child: _buildRecordCard(record, isDark, controller), + ); + }, + childCount: records.length, + ), + ), + ); + } + + Widget _buildRecordCard( + RatingRecordModel record, + bool isDark, + RatingRecordsController controller, + ) { + return Obx(() { + final isEditMode = controller.isEditMode.value; + final isSelected = controller.selectedIds.contains(record.recipeId); + + return GestureDetector( + onTap: () { + if (isEditMode) { + controller.toggleSelection(record.recipeId); + } else { + Get.toNamed( + AppRoutes.recipeDetail, + arguments: '${record.recipeId}', + ); + } + }, + onLongPress: () { + if (!isEditMode) { + controller.toggleEditMode(); + controller.toggleSelection(record.recipeId); + } + }, + child: GlassContainer( + padding: const EdgeInsets.all(DesignTokens.space3), + borderRadius: DesignTokens.radiusLg, + opacity: isDark ? 0.6 : 0.8, + borderColor: isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.6) + : null, + child: Row( + children: [ + if (isEditMode) + Padding( + padding: const EdgeInsets.only( + right: DesignTokens.space3), + child: Icon( + isSelected + ? CupertinoIcons.checkmark_square_fill + : CupertinoIcons.square, + size: 22, + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3), + ), + ), + _buildCoverImage(record, isDark), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + record.recipeTitle, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: DesignTokens.space1), + Row( + children: [ + if (record.categoryName != null) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary + .withValues(alpha: 0.08), + borderRadius: BorderRadius.circular( + DesignTokens.radiusSm), + ), + child: Text( + record.categoryName!, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, + ), + ), + ), + const SizedBox(width: DesignTokens.space2), + ], + Text( + record.displayDate, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + _buildScoreDisplay(record, isDark), + ], + ), + ), + if (!isEditMode) + Padding( + padding: const EdgeInsets.only( + left: DesignTokens.space2), + child: Icon( + CupertinoIcons.chevron_right, + size: 16, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + ); + }); + } + + Widget _buildCoverImage(RatingRecordModel record, bool isDark) { + return Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.glass : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: record.coverImage != null && record.coverImage!.isNotEmpty + ? ClipRRect( + borderRadius: DesignTokens.borderRadiusMd, + child: Image.network( + record.coverImage!, + width: 56, + height: 56, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => + _buildCoverPlaceholder(isDark), + ), + ) + : _buildCoverPlaceholder(isDark), + ); + } + + Widget _buildCoverPlaceholder(bool isDark) { + return Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: DesignTokens.primaryLight, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Icon( + CupertinoIcons.star_fill, + size: 24, + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.5), + ), + ); + } + + Widget _buildScoreDisplay(RatingRecordModel record, bool isDark) { + final scoreColor = _getScoreColor(record.score); + + return Row( + children: [ + ...List.generate(5, (index) { + final isFilled = index < record.score; + return Padding( + padding: const EdgeInsets.only(right: 2), + child: Icon( + isFilled ? CupertinoIcons.star_fill : CupertinoIcons.star, + size: 14, + color: isFilled + ? scoreColor + : (isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.3) + : DesignTokens.text3.withValues(alpha: 0.3)), + ), + ); + }), + const SizedBox(width: DesignTokens.space2), + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: scoreColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(DesignTokens.radiusSm), + ), + child: Text( + '${record.scoreEmoji} ${record.scoreLabel}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: scoreColor, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ); + } + + Color _getScoreColor(int score) { + switch (score) { + case 5: + return DesignTokens.gold; + case 4: + return DesignTokens.orange; + case 3: + return DesignTokens.dynamicPrimary; + case 2: + return DesignTokens.secondary; + case 1: + return DesignTokens.red; + default: + return DesignTokens.dynamicPrimary; + } + } + + // ─── Empty State ─── + + Widget _buildEmptyState(bool isDark) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: DesignTokens.primaryLight, + borderRadius: DesignTokens.borderRadiusXl, + ), + child: Icon( + CupertinoIcons.star, + size: 48, + color: DesignTokens.dynamicPrimary, + ), + ), + const SizedBox(height: DesignTokens.space4), + Text( + '暂无评分记录', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space2), + Text( + '浏览菜谱时点击⭐即可评分', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space4), + SizedBox( + width: 180, + child: CupertinoButton.filled( + borderRadius: DesignTokens.borderRadiusLg, + onPressed: () => Get.toNamed(AppRoutes.discover), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.compass, size: 18), + SizedBox(width: DesignTokens.space2), + Text('去发现'), + ], + ), + ), + ), + ], + ), + ); + } + + // ─── Floating Side Bar (Edit Mode) ─── + + Widget _buildFloatingSideBar( + bool isDark, RatingRecordsController controller) { + return Positioned( + right: DesignTokens.space3, + bottom: 120, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildFloatingButton( + isDark: isDark, + icon: CupertinoIcons.xmark_circle_fill, + label: '取消', + color: isDark ? DarkDesignTokens.glass : DesignTokens.card, + onTap: () => controller.toggleEditMode(), + ), + const SizedBox(height: DesignTokens.space2), + Obx(() { + final count = controller.selectedIds.length; + return _buildFloatingButton( + isDark: isDark, + icon: CupertinoIcons.trash_fill, + label: '删除($count)', + color: count > 0 + ? DesignTokens.red + : (isDark ? DarkDesignTokens.glass : DesignTokens.card), + onTap: count > 0 + ? () { + controller.deleteSelected(); + ToastService.show(message: '已删除评分记录'); + } + : () {}, + ); + }), + const SizedBox(height: DesignTokens.space2), + Obx(() { + final allSelected = + controller.selectedIds.length == controller.count; + return _buildFloatingButton( + isDark: isDark, + icon: allSelected + ? CupertinoIcons.checkmark_square_fill + : CupertinoIcons.square_fill, + label: allSelected ? '取消全选' : '全选', + color: DesignTokens.dynamicPrimary, + onTap: () { + if (allSelected) { + controller.deselectAll(); + } else { + controller.selectAll(); + } + }, + ); + }), + ], + ), + ); + } + + Widget _buildFloatingButton({ + required bool isDark, + required IconData icon, + required String label, + required Color color, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 56, + decoration: BoxDecoration( + color: color, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: DesignTokens.space2), + child: Icon( + icon, + size: 22, + color: color == DesignTokens.red || + color == DesignTokens.dynamicPrimary + ? CupertinoColors.white + : (isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space1, + vertical: 2, + ), + child: Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: color == DesignTokens.red || + color == DesignTokens.dynamicPrimary + ? CupertinoColors.white + : (isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1), + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } + + // ─── Export Sheet ─── + + void _showExportSheet( + bool isDark, RatingRecordsController controller) { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => CupertinoActionSheet( + title: const Text('导出评分记录'), + message: Text( + '共 ${controller.count} 条评分记录', + style: TextStyle( + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + actions: [ + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + ToastService.show(message: 'JSON 导出功能开发中'); + }, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.doc_text, size: 20), + SizedBox(width: 8), + Text('导出为 JSON'), + ], + ), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + ToastService.show(message: 'CSV 导出功能开发中'); + }, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.table, size: 20), + SizedBox(width: 8), + Text('导出为 CSV'), + ], + ), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(context), + isDestructiveAction: true, + child: const Text('取消'), + ), + ), + ); + } + + // ─── Sort Options ─── + + void _showSortOptions( + bool isDark, RatingRecordsController controller) { + final currentSort = controller.sortMode.value; + + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => CupertinoActionSheet( + title: const Text('排序方式'), + actions: [ + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + controller.setSortMode(RatingSortMode.newest); + }, + isDefaultAction: currentSort == RatingSortMode.newest, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🕐 最新评分'), + if (currentSort == RatingSortMode.newest) ...[ + const SizedBox(width: 8), + Icon( + CupertinoIcons.checkmark_alt, + size: 16, + color: DesignTokens.dynamicPrimary, + ), + ], + ], + ), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + controller.setSortMode(RatingSortMode.oldest); + }, + isDefaultAction: currentSort == RatingSortMode.oldest, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('⏳ 最早评分'), + if (currentSort == RatingSortMode.oldest) ...[ + const SizedBox(width: 8), + Icon( + CupertinoIcons.checkmark_alt, + size: 16, + color: DesignTokens.dynamicPrimary, + ), + ], + ], + ), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + controller.setSortMode(RatingSortMode.scoreHigh); + }, + isDefaultAction: currentSort == RatingSortMode.scoreHigh, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🌟 高分优先'), + if (currentSort == RatingSortMode.scoreHigh) ...[ + const SizedBox(width: 8), + Icon( + CupertinoIcons.checkmark_alt, + size: 16, + color: DesignTokens.dynamicPrimary, + ), + ], + ], + ), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + controller.setSortMode(RatingSortMode.scoreLow); + }, + isDefaultAction: currentSort == RatingSortMode.scoreLow, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('😐 低分优先'), + if (currentSort == RatingSortMode.scoreLow) ...[ + const SizedBox(width: 8), + Icon( + CupertinoIcons.checkmark_alt, + size: 16, + color: DesignTokens.dynamicPrimary, + ), + ], + ], + ), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(context), + isDestructiveAction: true, + child: const Text('取消'), + ), + ), + ); + } +} + +// ─── Scroll End Indicator ─── + +class _ScrollEndIndicator extends StatefulWidget { + final ScrollController scrollController; + final bool isDark; + + const _ScrollEndIndicator({ + required this.scrollController, + required this.isDark, + }); + + @override + State<_ScrollEndIndicator> createState() => _ScrollEndIndicatorState(); +} + +class _ScrollEndIndicatorState extends State<_ScrollEndIndicator> { + bool _isVisible = false; + + @override + void initState() { + super.initState(); + widget.scrollController.addListener(_onScroll); + } + + @override + void dispose() { + widget.scrollController.removeListener(_onScroll); + super.dispose(); + } + + void _onScroll() { + final isVisible = + widget.scrollController.position.pixels >= + widget.scrollController.position.maxScrollExtent - 80; + if (_isVisible != isVisible) { + setState(() => _isVisible = isVisible); + } + } + + @override + Widget build(BuildContext context) { + if (!_isVisible) return const SizedBox(height: 20); + + return Container( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space5), + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 24, + height: 1.5, + decoration: BoxDecoration( + color: widget.isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.4) + : DesignTokens.text3.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(1), + ), + ), + const SizedBox(width: DesignTokens.space3), + Text( + '到底了', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: widget.isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(width: DesignTokens.space3), + Container( + width: 24, + height: 1.5, + decoration: BoxDecoration( + color: widget.isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.4) + : DesignTokens.text3.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(1), + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space1), + Text( + '⭐ 已展示全部评分记录', + style: TextStyle( + fontSize: 10, + color: widget.isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.6) + : DesignTokens.text3.withValues(alpha: 0.6), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/pages/profile/shopping_list_page.dart b/lib/src/pages/profile/tools/shopping_list_page.dart similarity index 100% rename from lib/src/pages/profile/shopping_list_page.dart rename to lib/src/pages/profile/tools/shopping_list_page.dart diff --git a/lib/src/pages/tools/cooking/date_calculator_page.dart b/lib/src/pages/tools/cooking/calculator/date_calculator_page.dart similarity index 100% rename from lib/src/pages/tools/cooking/date_calculator_page.dart rename to lib/src/pages/tools/cooking/calculator/date_calculator_page.dart diff --git a/lib/src/pages/tools/cooking/food_copy_generator_page.dart b/lib/src/pages/tools/cooking/calculator/food_copy_generator_page.dart similarity index 100% rename from lib/src/pages/tools/cooking/food_copy_generator_page.dart rename to lib/src/pages/tools/cooking/calculator/food_copy_generator_page.dart diff --git a/lib/src/pages/tools/cooking/order_assistant_page.dart b/lib/src/pages/tools/cooking/calculator/order_assistant_page.dart similarity index 100% rename from lib/src/pages/tools/cooking/order_assistant_page.dart rename to lib/src/pages/tools/cooking/calculator/order_assistant_page.dart diff --git a/lib/src/pages/tools/cooking/serving_scaler_page.dart b/lib/src/pages/tools/cooking/calculator/serving_scaler_page.dart similarity index 100% rename from lib/src/pages/tools/cooking/serving_scaler_page.dart rename to lib/src/pages/tools/cooking/calculator/serving_scaler_page.dart diff --git a/lib/src/pages/tools/cooking/unit_converter_page.dart b/lib/src/pages/tools/cooking/calculator/unit_converter_page.dart similarity index 100% rename from lib/src/pages/tools/cooking/unit_converter_page.dart rename to lib/src/pages/tools/cooking/calculator/unit_converter_page.dart diff --git a/lib/src/pages/tools/cooking/decision_maker_page.dart b/lib/src/pages/tools/cooking/decision_maker_page.dart new file mode 100644 index 0000000..453699f --- /dev/null +++ b/lib/src/pages/tools/cooking/decision_maker_page.dart @@ -0,0 +1,1073 @@ +/* + * 文件: decision_maker_page.dart + * 名称: 帮我做决定页面 + * 作用: 转盘随机决策工具,支持模板/自定义选项,旋转动画选结果 + * 创建: 2026-04-19 初始创建 + * 更新: 2026-04-19 修复转盘指针指向分界线bug,改用预选结果+精确角度方案 + */ + +import 'dart:math'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; + +class _DecisionOption { + final String label; + final String emoji; + final Color color; + + const _DecisionOption(this.label, this.emoji, this.color); +} + +class _DecisionTemplate { + final String name; + final String emoji; + final List<_DecisionOption> options; + + const _DecisionTemplate(this.name, this.emoji, this.options); +} + +class DecisionMakerPage extends StatefulWidget { + const DecisionMakerPage({super.key}); + + @override + State createState() => _DecisionMakerPageState(); +} + +class _DecisionMakerPageState extends State + with TickerProviderStateMixin { + late AnimationController _spinController; + late AnimationController _resultController; + late Animation _spinAnimation; + late Animation _resultScaleAnimation; + late Animation _resultFadeAnimation; + + static const List _segmentColors = [ + Color(0xFFFF6B35), + Color(0xFF2ECC71), + Color(0xFF3498DB), + Color(0xFF9B59B6), + Color(0xFFF39C12), + Color(0xFFE74C3C), + Color(0xFF1ABC9C), + Color(0xFF34495E), + Color(0xFFFF9F1C), + Color(0xFF27AE60), + Color(0xFF2980B9), + Color(0xFF8E44AD), + ]; + + static const List<_DecisionTemplate> _templates = [ + _DecisionTemplate('今天吃什么', '🍜', [ + _DecisionOption('火锅', '🍲', Color(0xFFFF6B35)), + _DecisionOption('烧烤', '🥩', Color(0xFF2ECC71)), + _DecisionOption('面食', '🍜', Color(0xFF3498DB)), + _DecisionOption('米饭', '🍚', Color(0xFF9B59B6)), + _DecisionOption('汉堡', '🍔', Color(0xFFF39C12)), + _DecisionOption('沙拉', '🥗', Color(0xFFE74C3C)), + ]), + _DecisionTemplate('喝什么', '🧋', [ + _DecisionOption('奶茶', '🧋', Color(0xFFFF6B35)), + _DecisionOption('咖啡', '☕', Color(0xFF2ECC71)), + _DecisionOption('果汁', '🧃', Color(0xFF3498DB)), + _DecisionOption('白水', '💧', Color(0xFF9B59B6)), + _DecisionOption('可乐', '🥤', Color(0xFFF39C12)), + _DecisionOption('茶', '🍵', Color(0xFFE74C3C)), + ]), + _DecisionTemplate('做还是不做', '🤔', [ + _DecisionOption('做!', '💪', Color(0xFF2ECC71)), + _DecisionOption('不做', '🚫', Color(0xFFE74C3C)), + ]), + _DecisionTemplate('去哪里玩', '🏖️', [ + _DecisionOption('公园', '🌳', Color(0xFF2ECC71)), + _DecisionOption('商场', '🛍️', Color(0xFF3498DB)), + _DecisionOption('电影院', '🎬', Color(0xFF9B59B6)), + _DecisionOption('咖啡厅', '☕', Color(0xFFF39C12)), + _DecisionOption('在家躺', '🛋️', Color(0xFFE74C3C)), + ]), + _DecisionTemplate('选哪个', '🎯', [ + _DecisionOption('选项A', '🅰️', Color(0xFF3498DB)), + _DecisionOption('选项B', '🅱️', Color(0xFFE74C3C)), + _DecisionOption('选项C', '©️', Color(0xFF2ECC71)), + _DecisionOption('选项D', '🇩', Color(0xFFF39C12)), + ]), + ]; + + int _selectedTemplateIndex = 0; + bool _isCustomMode = false; + final List<_DecisionOption> _customOptions = []; + final TextEditingController _optionController = TextEditingController(); + String? _resultText; + String? _resultEmoji; + bool _isSpinning = false; + final List> _history = []; + final Random _random = Random(); + int? _pendingResultIndex; + + List<_DecisionOption> get _currentOptions { + if (_isCustomMode) return _customOptions; + return _templates[_selectedTemplateIndex].options; + } + + @override + void initState() { + super.initState(); + _spinController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 4000), + ); + _spinAnimation = Tween(begin: 0, end: 1).animate( + CurvedAnimation(parent: _spinController, curve: Curves.decelerate), + ); + + _resultController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + _resultScaleAnimation = Tween(begin: 0.5, end: 1.0).animate( + CurvedAnimation(parent: _resultController, curve: Curves.elasticOut), + ); + _resultFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _resultController, curve: Curves.easeOut), + ); + + _spinController.addStatusListener((status) { + if (status == AnimationStatus.completed) { + _onSpinComplete(); + } + }); + } + + @override + void dispose() { + _spinController.dispose(); + _resultController.dispose(); + _optionController.dispose(); + super.dispose(); + } + + void _onSpinComplete() { + final options = _currentOptions; + if (options.isEmpty) return; + + final selectedIndex = _pendingResultIndex ?? 0; + _pendingResultIndex = null; + + setState(() { + _resultText = options[selectedIndex].label; + _resultEmoji = options[selectedIndex].emoji; + _isSpinning = false; + _history.insert(0, { + 'label': options[selectedIndex].label, + 'emoji': options[selectedIndex].emoji, + }); + if (_history.length > 10) _history.removeLast(); + }); + + _resultController.forward(from: 0); + HapticFeedback.mediumImpact(); + } + + void _startSpin() { + if (_isSpinning || _currentOptions.length < 2) return; + + final options = _currentOptions; + final selectedIndex = _random.nextInt(options.length); + _pendingResultIndex = selectedIndex; + + final sectionAngle = 2 * pi / options.length; + final inSectionOffset = (_random.nextDouble() - 0.5) * 0.6; + final targetAngle = (selectedIndex + 0.5 + inSectionOffset) * sectionAngle; + final rotationToTarget = (2 * pi - targetAngle) % (2 * pi); + final extraSpins = 5 + _random.nextInt(5); + final totalRotation = extraSpins * 2 * pi + rotationToTarget; + + setState(() { + _isSpinning = true; + _resultText = null; + _resultEmoji = null; + }); + + _resultController.reverse(from: 0); + _spinController.reset(); + + _spinAnimation = Tween( + begin: 0, + end: totalRotation / (2 * pi), + ).animate( + CurvedAnimation(parent: _spinController, curve: Curves.decelerate), + ); + + _spinController.forward(); + } + + void _addCustomOption() { + final text = _optionController.text.trim(); + if (text.isEmpty) return; + if (_customOptions.length >= 12) return; + + final emojiList = [ + '✨', + '🌟', + '⭐', + '💫', + '🎯', + '🎲', + '🎪', + '🎨', + '🎭', + '🎵', + '🎶', + '🎸', + ]; + final colorIndex = _customOptions.length % _segmentColors.length; + + setState(() { + _customOptions.add( + _DecisionOption( + text, + emojiList[_customOptions.length % emojiList.length], + _segmentColors[colorIndex], + ), + ); + }); + _optionController.clear(); + } + + void _removeCustomOption(int index) { + setState(() { + _customOptions.removeAt(index); + }); + } + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final bgColor = isDark + ? DarkDesignTokens.background + : DesignTokens.background; + + return CupertinoPageScaffold( + backgroundColor: bgColor, + navigationBar: CupertinoNavigationBar( + middle: const Text('帮我做决定 🎯'), + border: null, + backgroundColor: bgColor.withValues(alpha: 0.85), + ), + child: SafeArea( + child: ListView( + padding: const EdgeInsets.all(DesignTokens.space4), + children: [ + _buildModeSwitch(isDark), + const SizedBox(height: DesignTokens.space4), + if (!_isCustomMode) _buildTemplateSelector(isDark), + if (_isCustomMode) _buildCustomInput(isDark), + const SizedBox(height: DesignTokens.space4), + _buildSpinWheel(isDark), + const SizedBox(height: DesignTokens.space5), + _buildSpinButton(), + if (_resultText != null) ...[ + const SizedBox(height: DesignTokens.space5), + _buildResultCard(isDark), + ], + if (_history.isNotEmpty) ...[ + const SizedBox(height: DesignTokens.space5), + _buildHistorySection(isDark), + ], + const SizedBox(height: DesignTokens.space6), + ], + ), + ), + ); + } + + Widget _buildModeSwitch(bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.glassBorder, + ), + ), + child: CupertinoSlidingSegmentedControl( + groupValue: _isCustomMode, + thumbColor: DesignTokens.dynamicPrimary, + padding: const EdgeInsets.all(3), + children: { + false: Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space1, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('📋', style: TextStyle(fontSize: 14)), + const SizedBox(width: 4), + Text( + '模板', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: !_isCustomMode + ? CupertinoColors.white + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + ), + ], + ), + ), + true: Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space1, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('✏️', style: TextStyle(fontSize: 14)), + const SizedBox(width: 4), + Text( + '自定义', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: _isCustomMode + ? CupertinoColors.white + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + ), + ], + ), + ), + }, + onValueChanged: (value) { + if (value == null) return; + setState(() { + _isCustomMode = value; + _resultText = null; + _resultEmoji = null; + }); + }, + ), + ); + } + + Widget _buildTemplateSelector(bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.glassBorder, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('📋', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Text( + '选择模板', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Wrap( + spacing: DesignTokens.space2, + runSpacing: DesignTokens.space2, + children: List.generate(_templates.length, (index) { + final template = _templates[index]; + final isSelected = index == _selectedTemplateIndex; + return GestureDetector( + onTap: () => setState(() { + _selectedTemplateIndex = index; + _resultText = null; + _resultEmoji = null; + }), + child: AnimatedContainer( + duration: DesignTokens.durationFast, + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.12) + : (isDark + ? DarkDesignTokens.background + : DesignTokens.background), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.glassBorder), + width: isSelected ? 1.5 : 0.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + template.emoji, + style: const TextStyle(fontSize: 14), + ), + const SizedBox(width: 4), + Text( + template.name, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + ), + ], + ), + ), + ); + }), + ), + ], + ), + ); + } + + Widget _buildCustomInput(bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.glassBorder, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('✏️', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Text( + '自定义选项', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const Spacer(), + Text( + '${_customOptions.length}/12', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Row( + children: [ + Expanded( + child: CupertinoTextField( + controller: _optionController, + placeholder: '输入选项名称...', + placeholderStyle: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.glassBorder, + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + onSubmitted: (_) => _addCustomOption(), + ), + ), + const SizedBox(width: DesignTokens.space2), + GestureDetector( + onTap: _addCustomOption, + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: const Icon( + CupertinoIcons.add, + color: CupertinoColors.white, + size: 20, + ), + ), + ), + ], + ), + if (_customOptions.isNotEmpty) ...[ + const SizedBox(height: DesignTokens.space3), + Wrap( + spacing: DesignTokens.space2, + runSpacing: DesignTokens.space2, + children: List.generate(_customOptions.length, (index) { + final option = _customOptions[index]; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: option.color.withValues(alpha: 0.12), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: option.color.withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(option.emoji, style: const TextStyle(fontSize: 12)), + const SizedBox(width: 3), + Text( + option.label, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: option.color, + ), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () => _removeCustomOption(index), + child: Icon( + CupertinoIcons.clear_circled_solid, + size: 14, + color: option.color.withValues(alpha: 0.6), + ), + ), + ], + ), + ); + }), + ), + ], + if (_customOptions.isEmpty) ...[ + const SizedBox(height: DesignTokens.space3), + Center( + child: Text( + '至少添加 2 个选项才能转盘', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ), + ], + ], + ), + ); + } + + Widget _buildSpinWheel(bool isDark) { + final options = _currentOptions; + final canSpin = options.length >= 2; + + return Container( + padding: const EdgeInsets.all(DesignTokens.space5), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusXl, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.glassBorder, + ), + boxShadow: [ + BoxShadow( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.06), + blurRadius: 30, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + children: [ + _buildPointer(isDark), + const SizedBox(height: DesignTokens.space2), + SizedBox( + width: 280, + height: 280, + child: canSpin + ? AnimatedBuilder( + animation: _spinController, + builder: (context, child) { + return Transform.rotate( + angle: _spinAnimation.value * 2 * pi, + child: child, + ); + }, + child: PieChart( + PieChartData( + sections: _buildPieSections(options, isDark), + sectionsSpace: 2, + centerSpaceRadius: 40, + centerSpaceColor: isDark + ? DarkDesignTokens.card + : DesignTokens.card, + startDegreeOffset: -90, + ), + duration: Duration.zero, + ), + ) + : Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🎯', style: TextStyle(fontSize: 48)), + const SizedBox(height: DesignTokens.space3), + Text( + '添加选项开始转盘', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + ), + const SizedBox(height: DesignTokens.space3), + if (canSpin) _buildOptionLegend(options, isDark), + ], + ), + ); + } + + List _buildPieSections( + List<_DecisionOption> options, + bool isDark, + ) { + return options.asMap().entries.map((entry) { + final option = entry.value; + return PieChartSectionData( + value: 1, + color: option.color, + radius: 110, + title: option.label.length > 4 + ? '${option.label.substring(0, 4)}…' + : option.label, + titleStyle: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w700, + color: CupertinoColors.white, + shadows: [ + Shadow(color: Colors.black.withValues(alpha: 0.4), blurRadius: 4), + ], + ), + titlePositionPercentageOffset: 0.6, + borderSide: BorderSide( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + width: 1, + ), + ); + }).toList(); + } + + Widget _buildPointer(bool isDark) { + return CustomPaint( + size: const Size(40, 30), + painter: _PointerPainter( + color: DesignTokens.dynamicPrimary, + shadowColor: DesignTokens.dynamicPrimary.withValues(alpha: 0.4), + ), + ); + } + + Widget _buildOptionLegend(List<_DecisionOption> options, bool isDark) { + return Wrap( + spacing: DesignTokens.space2, + runSpacing: DesignTokens.space1, + alignment: WrapAlignment.center, + children: options.map((option) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: option.color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 3), + Text( + '${option.emoji} ${option.label}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ); + }).toList(), + ); + } + + Widget _buildSpinButton() { + final canSpin = _currentOptions.length >= 2 && !_isSpinning; + + return SizedBox( + width: double.infinity, + height: 52, + child: CupertinoButton( + borderRadius: DesignTokens.borderRadiusFull, + color: DesignTokens.dynamicPrimary, + onPressed: canSpin ? _startSpin : null, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_isSpinning) + const CupertinoActivityIndicator(color: CupertinoColors.white) + else + const Text('🎲', style: TextStyle(fontSize: 20)), + const SizedBox(width: DesignTokens.space2), + Text( + _isSpinning + ? '转盘旋转中...' + : (_resultText != null ? '再转一次' : '开始转盘'), + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: CupertinoColors.white, + ), + ), + ], + ), + ), + ); + } + + Widget _buildResultCard(bool isDark) { + return AnimatedBuilder( + animation: _resultController, + builder: (context, child) { + return Transform.scale( + scale: _resultScaleAnimation.value, + child: Opacity(opacity: _resultFadeAnimation.value, child: child), + ); + }, + child: Container( + padding: const EdgeInsets.all(DesignTokens.space5), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + DesignTokens.dynamicPrimary.withValues(alpha: 0.15), + DesignTokens.secondary.withValues(alpha: 0.08), + ], + ), + borderRadius: DesignTokens.borderRadiusXl, + border: Border.all( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.2), + ), + boxShadow: [ + BoxShadow( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + children: [ + Text( + '🎉 就决定是你了!', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space4), + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.12), + borderRadius: DesignTokens.borderRadiusXl, + ), + child: Center( + child: Text( + _resultEmoji ?? '🎯', + style: const TextStyle(fontSize: 40), + ), + ), + ), + const SizedBox(height: DesignTokens.space3), + Text( + _resultText ?? '', + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w800, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space3), + GestureDetector( + onTap: () { + if (_resultText != null) { + Clipboard.setData( + ClipboardData(text: '$_resultEmoji $_resultText'), + ); + HapticFeedback.lightImpact(); + _showCopiedToast(); + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.doc_on_clipboard, + size: 14, + color: DesignTokens.dynamicPrimary, + ), + const SizedBox(width: 4), + Text( + '复制结果', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: DesignTokens.dynamicPrimary, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + void _showCopiedToast() { + showCupertinoDialog( + context: context, + barrierDismissible: true, + builder: (ctx) => CupertinoAlertDialog( + content: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('✅ ', style: TextStyle(fontSize: 18)), + Text('已复制到剪贴板'), + ], + ), + actions: [ + CupertinoDialogAction( + child: const Text('好'), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + ); + } + + Widget _buildHistorySection(bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.glassBorder, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('📝', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Text( + '决定历史', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const Spacer(), + GestureDetector( + onTap: () => setState(() => _history.clear()), + child: Text( + '清空', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.red, + ), + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + ..._history.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + final isLatest = index == 0; + return Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space2), + child: Row( + children: [ + if (isLatest) + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space1, + vertical: 1, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues( + alpha: 0.1, + ), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '最新', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: DesignTokens.dynamicPrimary, + ), + ), + ) + else + const SizedBox(width: 24), + const SizedBox(width: DesignTokens.space2), + Text( + item['emoji'] ?? '', + style: const TextStyle(fontSize: 16), + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Text( + item['label'] ?? '', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: isLatest + ? FontWeight.w600 + : FontWeight.normal, + color: isLatest + ? (isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1) + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + ), + ), + ], + ), + ); + }), + ], + ), + ); + } +} + +class _PointerPainter extends CustomPainter { + final Color color; + final Color shadowColor; + + _PointerPainter({required this.color, required this.shadowColor}); + + @override + void paint(Canvas canvas, Size size) { + final path = Path() + ..moveTo(size.width / 2, size.height) + ..lineTo(size.width / 2 - 12, 0) + ..lineTo(size.width / 2 + 12, 0) + ..close(); + + canvas.drawShadow(path, shadowColor, 6, false); + + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + canvas.drawPath(path, paint); + + final borderPaint = Paint() + ..color = CupertinoColors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + + canvas.drawPath(path, borderPaint); + } + + @override + bool shouldRepaint(covariant _PointerPainter oldDelegate) { + return color != oldDelegate.color || shadowColor != oldDelegate.shadowColor; + } +} diff --git a/lib/src/pages/tools/cooking_tip_detail_page.dart b/lib/src/pages/tools/ingredient/cooking_tip_detail_page.dart similarity index 93% rename from lib/src/pages/tools/cooking_tip_detail_page.dart rename to lib/src/pages/tools/ingredient/cooking_tip_detail_page.dart index 504cd31..a64c74d 100644 --- a/lib/src/pages/tools/cooking_tip_detail_page.dart +++ b/lib/src/pages/tools/ingredient/cooking_tip_detail_page.dart @@ -247,23 +247,23 @@ class _CookingTipDetailPageState extends State { : DesignTokens.text3.withValues(alpha: 0.1), ), const SizedBox(height: DesignTokens.space2), - Text( - '📖 数据来源于 HowToCook 开源项目', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: DesignTokens.space1), - Text( - 'github.com/Anduin2017/HowToCook', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: DesignTokens.dynamicPrimary, - ), - textAlign: TextAlign.center, - ), + // Text( + // '📖 数据来源于 HowToCook 开源项目', + // style: TextStyle( + // fontSize: DesignTokens.fontXs, + // color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + // ), + // textAlign: TextAlign.center, + // ), + // const SizedBox(height: DesignTokens.space1), + // Text( + // 'github.com/Anduin2017/HowToCook', + // style: TextStyle( + // fontSize: DesignTokens.fontXs, + // color: DesignTokens.dynamicPrimary, + // ), + // textAlign: TextAlign.center, + // ), ], ), ); diff --git a/lib/src/pages/tools/cooking_tips_list_page.dart b/lib/src/pages/tools/ingredient/cooking_tips_list_page.dart similarity index 100% rename from lib/src/pages/tools/cooking_tips_list_page.dart rename to lib/src/pages/tools/ingredient/cooking_tips_list_page.dart diff --git a/lib/src/pages/tools/duplicate_check_page.dart b/lib/src/pages/tools/ingredient/duplicate_check_page.dart similarity index 100% rename from lib/src/pages/tools/duplicate_check_page.dart rename to lib/src/pages/tools/ingredient/duplicate_check_page.dart diff --git a/lib/src/pages/tools/ingredient_manage_page.dart b/lib/src/pages/tools/ingredient/ingredient_manage_page.dart similarity index 100% rename from lib/src/pages/tools/ingredient_manage_page.dart rename to lib/src/pages/tools/ingredient/ingredient_manage_page.dart diff --git a/lib/src/pages/tools/ingredient_detail_page.dart.bak b/lib/src/pages/tools/ingredient_detail_page.dart.bak deleted file mode 100644 index 00625ea..0000000 --- a/lib/src/pages/tools/ingredient_detail_page.dart.bak +++ /dev/null @@ -1,1932 +0,0 @@ -/* - * 文件: ingredient_detail_page.dart - * 名称: 食材详情查询页面 - * 作用: 查询食材营养信息与选购指南 - * 创建: 2026-04-10 - * 更新: 2026-04-14 使用新的食材缓存服务,访问后自动缓存 - */ - -import 'package:flutter/cupertino.dart'; -import 'package:get/get.dart'; -import 'package:mom_kitchen/src/config/design_tokens.dart'; -import 'package:mom_kitchen/src/models/recipe/ingredient_model.dart'; -import 'package:mom_kitchen/src/models/recipe/recipe_model.dart'; -import 'package:mom_kitchen/src/repositories/recipe_repository.dart'; -import 'package:mom_kitchen/src/services/data/ingredient_nutrition_db.dart'; -import 'package:mom_kitchen/src/services/data/ingredient_cache_service.dart'; - -class IngredientDetailPage extends StatefulWidget { - const IngredientDetailPage({super.key}); - - @override - State createState() => _IngredientDetailPageState(); -} - -class _IngredientDetailPageState extends State { - final TextEditingController _searchController = TextEditingController(); - final RecipeRepository _recipeRepository = RecipeRepository(); - final IngredientCacheService _cacheService = IngredientCacheService(); - - List> _ingredients = []; - List> _filteredIngredients = []; - Map? _selectedIngredient; - IngredientDetail? _passedDetail; - IngredientModel? _ingredientDetail; - bool _isLoading = false; - bool _isLoadingDetail = false; - String _searchQuery = ''; - List _similarRecipes = []; - bool _isLoadingSimilar = false; - - bool _ingredientsLoaded = false; - - int _detailRequestToken = 0; - int _similarRequestToken = 0; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - _initData(); - }); - } - - /// 统一初始化:有参数时优先加载详情,tags列表后台加载 - Future _initData() async { - final args = Get.arguments; - - // 解析参数 - String? ingredientName; - int? ingredientId; - IngredientDetail? passedDetail; - - if (args is String) { - ingredientName = args; - } else if (args is Map) { - ingredientName = args['name'] as String?; - ingredientId = args['id'] as int?; - final detailMap = args['detail']; - if (detailMap is IngredientDetail) { - passedDetail = detailMap; - } else if (detailMap is Map) { - try { - passedDetail = IngredientDetail.fromJson(detailMap); - } catch (e) { - debugPrint('Failed to parse IngredientDetail: $e'); - } - } - } - - // 有页面参数时:立即设置状态,避免先显示列表页 - if (ingredientName != null && ingredientName.isNotEmpty) { - if (!mounted) return; - setState(() { - _passedDetail = passedDetail; - _selectedIngredient = { - 'id': ingredientId, - 'name': ingredientName, - 'count': 0, - 'category': _getIngredientCategory(ingredientName!), - }; - _isLoadingDetail = true; - }); - - // 初始化缓存服务(单例,只需初始化一次) - if (!_cacheService.isInitialized) { - await _cacheService.init(); - } - - // 后台加载tags列表(不阻塞) - _loadIngredientsInBackground(); - - // 有id时直接加载详情 - if (ingredientId != null && ingredientId > 0) { - _loadDetailDirectly(ingredientName, ingredientId); - } else { - // 无id,走名称查找流程 - _loadIngredientByName(ingredientName, ingredientId); - } - } else { - // 无参数:初始化缓存服务,正常加载tags列表 - if (!_cacheService.isInitialized) { - await _cacheService.init(); - } - await _loadIngredients(); - _ingredientsLoaded = true; - } - } - - /// 后台加载食材列表,不阻塞详情加载 - Future _loadIngredientsInBackground() async { - await _loadIngredients(); - _ingredientsLoaded = true; - } - - /// 直接加载详情 — 有id时优先从缓存加载 - Future _loadDetailDirectly(String name, int id) async { - debugPrint( - 'IngredientDetailPage: _loadDetailDirectly 开始 name=$name, id=$id', - ); - debugPrint( - 'IngredientDetailPage: 缓存服务状态 initialized=${_cacheService.isInitialized}', - ); - - // 先从缓存加载 - final cached = _cacheService.getById(id); - debugPrint( - 'IngredientDetailPage: 缓存查询结果 cached=${cached != null ? "命中" : "未命中"}', - ); - - if (cached != null) { - debugPrint('IngredientDetailPage: ✅ 从缓存加载 id=$id, name=${cached.name}'); - if (!mounted) return; - setState(() { - _selectedIngredient = { - 'id': id, - 'name': cached.name, - 'count': cached.effectiveRecipeCount, - 'category': _getIngredientCategory(cached.name), - }; - _ingredientDetail = cached; - _isLoadingDetail = false; - }); - debugPrint('IngredientDetailPage: 缓存数据已设置到UI,开始加载相似菜谱'); - _loadSimilarRecipes(cached.name); - return; - } - - // 缓存未命中,请求API(_isLoadingDetail 已在 _initData 中设置为 true) - debugPrint('IngredientDetailPage: ❌ 缓存未命中,请求API id=$id'); - final token = ++_detailRequestToken; - try { - final detail = await _recipeRepository.fetchIngredientDetail(id); - if (mounted && token == _detailRequestToken) { - // 写入缓存 - await _cacheService.save(detail); - debugPrint('IngredientDetailPage: 已缓存 id=$id, name=${detail.name}'); - setState(() { - _selectedIngredient = { - 'id': id, - 'name': detail.name, - 'count': detail.effectiveRecipeCount, - 'category': _getIngredientCategory(detail.name), - }; - _ingredientDetail = detail; - _isLoadingDetail = false; - }); - } - } catch (e) { - debugPrint('IngredientDetailPage: ❌ 加载食材详情失败: $e'); - if (mounted && token == _detailRequestToken) { - setState(() => _isLoadingDetail = false); - } - } - - _loadSimilarRecipes(name); - } - - Future _loadIngredientByName(String name, int? id) async { - debugPrint( - 'IngredientDetailPage: _loadIngredientByName name=$name, id=$id', - ); - - // 优先从缓存按名称查找 - final cached = _cacheService.getByName(name); - if (cached != null) { - debugPrint('IngredientDetailPage: 从缓存加载 name=$name, id=${cached.id}'); - if (!mounted) return; - setState(() { - _selectedIngredient = { - 'id': cached.id, - 'name': cached.name, - 'count': cached.effectiveRecipeCount, - 'category': _getIngredientCategory(cached.name), - }; - _ingredientDetail = cached; - _isLoadingDetail = false; - }); - // 不再后台更新,直接使用缓存数据 - _loadSimilarRecipes(cached.name); - return; - } - - // 缓存未命中,走API流程 - // 优先使用传入的id(最快路径,无需等待任何API) - var resolvedId = id; - - // 如果没有传入id,先从tags列表中查找 - if (resolvedId == null || resolvedId <= 0) { - if (!_ingredientsLoaded) { - debugPrint('IngredientDetailPage: 等待食材列表加载...'); - await _loadIngredients(); - _ingredientsLoaded = true; - } - - final ingredient = _ingredients.firstWhere( - (ing) => ing['name'] == name, - orElse: () => { - 'id': null, - 'name': name, - 'count': 0, - 'category': _getIngredientCategory(name), - }, - ); - resolvedId = ingredient['id'] as int?; - } - - // 如果tags列表也没找到,通过搜索API查找 - if (resolvedId == null || resolvedId <= 0) { - debugPrint('IngredientDetailPage: tags列表未找到,通过搜索API查找 name=$name'); - try { - final searchResult = await _recipeRepository.searchIngredientByName( - name, - ); - if (searchResult != null && searchResult.id > 0) { - resolvedId = searchResult.id; - debugPrint( - 'IngredientDetailPage: 搜索API找到 id=$resolvedId, name=${searchResult.name}', - ); - } else { - debugPrint('IngredientDetailPage: 搜索API未找到食材 name=$name'); - } - } catch (e) { - debugPrint('IngredientDetailPage: 搜索API异常: $e'); - } - } - - debugPrint( - 'IngredientDetailPage: _loadIngredientByName 最终结果 name=$name, resolvedId=$resolvedId', - ); - - // 请求食材详情 - if (resolvedId != null && resolvedId > 0) { - _loadIngredientDetailApi(resolvedId); - } else { - debugPrint('IngredientDetailPage: ⚠️ 无法请求API,resolvedId无效: $resolvedId'); - if (mounted) { - setState(() { - _isLoadingDetail = false; - }); - } - } - - _loadSimilarRecipes(name); - } - - Future _loadSimilarRecipes(String ingredientName) async { - if (!mounted) return; - final token = ++_similarRequestToken; - setState(() => _isLoadingSimilar = true); - - try { - final result = await _recipeRepository.search(ingredientName, limit: 6); - if (mounted && token == _similarRequestToken) { - setState(() { - _similarRecipes = result.items; - _isLoadingSimilar = false; - }); - } - } catch (e) { - debugPrint('Load similar recipes error: $e'); - if (mounted && token == _similarRequestToken) { - setState(() => _isLoadingSimilar = false); - } - } - } - - Future _loadIngredientDetailApi(int id) async { - debugPrint('IngredientDetailPage: _loadIngredientDetailApi id=$id'); - - // 先从缓存加载 - final cached = _cacheService.getById(id); - if (cached != null) { - debugPrint('IngredientDetailPage: 从缓存加载 id=$id'); - if (mounted) { - setState(() { - _selectedIngredient = { - 'id': id, - 'name': cached.name, - 'count': cached.effectiveRecipeCount, - 'category': _getIngredientCategory(cached.name), - }; - _ingredientDetail = cached; - _isLoadingDetail = false; - }); - } - // 不再后台更新,直接使用缓存数据 - return; - } - - // 缓存未命中,请求API(_isLoadingDetail 已在 _initData 中设置) - final token = ++_detailRequestToken; - try { - final detail = await _recipeRepository.fetchIngredientDetail(id); - debugPrint( - 'IngredientDetailPage: fetchIngredientDetail返回 id=${detail.id}, name=${detail.name}', - ); - // 写入缓存 - await _cacheService.save(detail); - debugPrint('IngredientDetailPage: 已缓存 id=$id'); - if (mounted && token == _detailRequestToken) { - setState(() { - _selectedIngredient = { - 'id': id, - 'name': detail.name, - 'count': detail.effectiveRecipeCount, - 'category': _getIngredientCategory(detail.name), - }; - _ingredientDetail = detail; - _isLoadingDetail = false; - }); - } - } catch (e) { - debugPrint('IngredientDetailPage: ❌ Load ingredient detail error: $e'); - if (mounted && token == _detailRequestToken) { - setState(() => _isLoadingDetail = false); - } - } - } - - Future _loadIngredients() async { - if (!mounted) return; - setState(() => _isLoading = true); - - try { - final tags = await _recipeRepository.fetchTags(); - if (!mounted) return; - setState(() { - _ingredients = tags.map((t) { - return { - 'id': t.id, - 'name': t.name, - 'count': t.count ?? 0, - 'category': _getIngredientCategory(t.name), - }; - }).toList(); - _filteredIngredients = _ingredients.take(50).toList(); - _isLoading = false; - }); - } catch (e) { - debugPrint('Load ingredients error: $e'); - if (!mounted) return; - setState(() => _isLoading = false); - } - } - - String _getIngredientCategory(String name) { - if (name.contains('鸡') || - name.contains('猪') || - name.contains('牛') || - name.contains('羊') || - name.contains('鱼') || - name.contains('虾') || - name.contains('蟹') || - name.contains('肉')) { - return '肉类'; - } else if (name.contains('菜') || - name.contains('椒') || - name.contains('葱') || - name.contains('蒜') || - name.contains('姜') || - name.contains('茄') || - name.contains('瓜') || - name.contains('豆角') || - name.contains('芹') || - name.contains('韭') || - name.contains('藕') || - name.contains('笋') || - name.contains('菇') || - name.contains('蘑') || - name.contains('耳') || - name.contains('带') || - name.contains('萝') || - name.contains('薯') || - name.contains('米') && name.contains('玉')) { - return '蔬菜'; - } else if (name.contains('油') || - name.contains('盐') || - name.contains('酱') || - name.contains('醋') || - name.contains('糖') || - name.contains('蜜') || - name.contains('味') || - name.contains('料') || - name.contains('酒')) { - return '调料'; - } else if (name.contains('面') || - name.contains('米') || - name.contains('粉') || - name.contains('饼') || - name.contains('馒头') || - name.contains('包')) { - return '主食'; - } else if (name.contains('蛋') || - name.contains('奶') || - name.contains('酪') || - name.contains('腐') || - name.contains('豆')) { - return '蛋奶豆'; - } else if (name.contains('果') || - name.contains('桃') || - name.contains('蕉') || - name.contains('莓') || - name.contains('橙') || - name.contains('橘') || - name.contains('柠') || - name.contains('瓜') && - !name.contains('冬') && - !name.contains('南') && - !name.contains('黄')) { - return '水果'; - } else if (name.contains('坚果') || - name.contains('花生') || - name.contains('芝麻') || - name.contains('核桃') || - name.contains('腰果') || - name.contains('栗') || - name.contains('杏')) { - return '坚果'; - } - return '其他'; - } - - void _filterIngredients(String query) { - setState(() { - _searchQuery = query; - if (query.isEmpty) { - _filteredIngredients = _ingredients.take(50).toList(); - } else { - _filteredIngredients = _ingredients.where((ing) { - final name = (ing['name'] as String? ?? '').toLowerCase(); - return name.contains(query.toLowerCase()); - }).toList(); - } - }); - } - - @override - Widget build(BuildContext context) { - final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; - - return CupertinoPageScaffold( - backgroundColor: isDark - ? DarkDesignTokens.background - : DesignTokens.background, - navigationBar: CupertinoNavigationBar( - middle: Text( - '🥕 食材详情', - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - trailing: _isLoadingDetail - ? const CupertinoActivityIndicator(radius: 10) - : null, - backgroundColor: isDark - ? DarkDesignTokens.background.withValues(alpha: 0.9) - : DesignTokens.background.withValues(alpha: 0.9), - border: null, - ), - child: SafeArea( - top: false, - child: Column( - children: [ - _buildSearchBar(isDark), - const SizedBox(height: DesignTokens.space2), - Expanded(child: _buildContent(isDark)), - ], - ), - ), - ); - } - - Widget _buildSearchBar(bool isDark) { - return Padding( - padding: const EdgeInsets.all(DesignTokens.space4), - child: Container( - height: 40, - decoration: BoxDecoration( - color: isDark - ? DarkDesignTokens.segmentedBg - : DesignTokens.text3.withValues(alpha: 0.08), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Row( - children: [ - const SizedBox(width: 12), - Icon( - CupertinoIcons.search, - size: 18, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, - ), - const SizedBox(width: 8), - Expanded( - child: CupertinoTextField( - controller: _searchController, - placeholder: '搜索食材...', - placeholderStyle: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, - ), - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - decoration: null, - onChanged: _filterIngredients, - ), - ), - if (_searchQuery.isNotEmpty) - CupertinoButton( - padding: const EdgeInsets.only(right: 8), - minimumSize: const Size(32, 32), - onPressed: () { - _searchController.clear(); - _filterIngredients(''); - }, - child: Icon( - CupertinoIcons.clear_circled_solid, - size: 18, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, - ), - ), - ], - ), - ), - ); - } - - Widget _buildContent(bool isDark) { - // 优先显示食材详情(即使后台在加载tags列表) - if (_selectedIngredient != null) { - return _buildIngredientDetail(isDark); - } - - if (_isLoading) { - return const Center(child: CupertinoActivityIndicator(radius: 16)); - } - - if (_filteredIngredients.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('🔍', style: TextStyle(fontSize: 48)), - const SizedBox(height: DesignTokens.space3), - Text( - '未找到相关食材', - style: TextStyle( - fontSize: DesignTokens.fontMd, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - ], - ), - ); - } - - return ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), - itemCount: _filteredIngredients.length, - separatorBuilder: (context, index) => - const SizedBox(height: DesignTokens.space2), - itemBuilder: (context, index) { - return _buildIngredientCard(_filteredIngredients[index], isDark); - }, - ); - } - - Widget _buildIngredientCard(Map ingredient, bool isDark) { - final name = ingredient['name'] as String? ?? ''; - final count = ingredient['count'] as int? ?? 0; - final category = ingredient['category'] as String? ?? ''; - final nutrition = IngredientNutritionDb.lookup(name); - - return GestureDetector( - onTap: () { - setState(() { - _selectedIngredient = ingredient; - }); - }, - child: Container( - padding: EdgeInsets.all(DesignTokens.space3), - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - borderRadius: DesignTokens.borderRadiusMd, - boxShadow: DesignTokens.shadowsSm, - ), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: (DesignTokens.dynamicPrimary).withValues(alpha: 0.1), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Center( - child: Text( - _getIngredientEmoji(category), - style: const TextStyle(fontSize: 24), - ), - ), - ), - const SizedBox(width: DesignTokens.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, - ), - ), - const SizedBox(height: 4), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: DesignTokens.green.withValues(alpha: 0.1), - borderRadius: DesignTokens.borderRadiusSm, - ), - child: Text( - category, - style: TextStyle( - fontSize: 10, - color: DesignTokens.green, - ), - ), - ), - const SizedBox(width: 8), - if (nutrition != null) ...[ - Text( - '${nutrition.calories.toInt()} kcal/${nutrition.unit}', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, - ), - ), - const SizedBox(width: 8), - ], - Text( - '$count 道菜谱', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, - ), - ), - ], - ), - ], - ), - ), - Icon( - CupertinoIcons.chevron_right, - size: 16, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, - ), - ], - ), - ), - ); - } - - Widget _buildIngredientDetail(bool isDark) { - final name = _selectedIngredient?['name'] as String? ?? ''; - final category = _selectedIngredient?['category'] as String? ?? ''; - final count = _selectedIngredient?['count'] as int? ?? 0; - - final nutrition = - IngredientNutritionDb.lookup(name) ?? - IngredientNutritionDb.getFallback(name, category); - - return ListView( - padding: const EdgeInsets.all(DesignTokens.space4), - children: [ - _buildDetailHeader(name, category, nutrition, isDark), - const SizedBox(height: DesignTokens.space4), - _buildCalorieOverview(nutrition, isDark), - const SizedBox(height: DesignTokens.space3), - _buildNutritionBars(nutrition, isDark), - const SizedBox(height: DesignTokens.space3), - _buildKeyNutrientsCard(nutrition, isDark), - const SizedBox(height: DesignTokens.space3), - _buildSeasonCard(nutrition, isDark), - const SizedBox(height: DesignTokens.space3), - _buildPurchaseTipCard(nutrition, isDark), - const SizedBox(height: DesignTokens.space3), - _buildStorageTipCard(nutrition, isDark), - const SizedBox(height: DesignTokens.space3), - if (_ingredientDetail != null) - _buildApiDetailCard(_ingredientDetail!, isDark) - else if (_passedDetail != null) - _buildPassedDetailCard(_passedDetail!, isDark), - _buildSubstitutionSection(name, isDark), - const SizedBox(height: DesignTokens.space4), - CupertinoButton.filled( - borderRadius: DesignTokens.borderRadiusMd, - onPressed: () { - Get.toNamed('/search', arguments: name); - }, - child: Text('🔍 查看 $count 道相关菜谱'), - ), - if (_similarRecipes.isNotEmpty) ...[ - const SizedBox(height: DesignTokens.space5), - _buildSimilarRecipesSection(name, isDark), - ], - const SizedBox(height: DesignTokens.space3), - CupertinoButton( - onPressed: () { - Get.until((route) => route.isFirst); - }, - child: const Text('返回首页'), - ), - const SizedBox(height: DesignTokens.space4), - ], - ); - } - - Widget _buildPassedDetailCard(IngredientDetail detail, bool isDark) { - final hasContent = - (detail.introduction?.isNotEmpty ?? false) || - (detail.nutrition?.isNotEmpty ?? false) || - (detail.guidance?.isNotEmpty ?? false) || - (detail.effect?.isNotEmpty ?? false) || - detail.allergen.isNotEmpty; - - if (!hasContent) return const SizedBox(); - - return Container( - padding: EdgeInsets.all(DesignTokens.space3), - margin: EdgeInsets.only(bottom: DesignTokens.space3), - decoration: BoxDecoration( - color: (DesignTokens.dynamicPrimary).withValues(alpha: 0.08), - borderRadius: DesignTokens.borderRadiusMd, - border: Border.all( - color: (DesignTokens.dynamicPrimary).withValues(alpha: 0.2), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text('📖', style: TextStyle(fontSize: 16)), - const SizedBox(width: DesignTokens.space2), - Text( - '食材详解', - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - ], - ), - if (detail.introduction?.isNotEmpty ?? false) ...[ - const SizedBox(height: DesignTokens.space2), - Text( - detail.introduction!, - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - ], - if (detail.nutrition?.isNotEmpty ?? false) ...[ - const SizedBox(height: DesignTokens.space2), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '📊 营养: ', - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w500, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - Expanded( - child: Text( - detail.nutrition!, - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark - ? DarkDesignTokens.text2 - : DesignTokens.text2, - ), - ), - ), - ], - ), - ], - if (detail.guidance?.isNotEmpty ?? false) ...[ - const SizedBox(height: DesignTokens.space2), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '🛒 选购: ', - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w500, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - Expanded( - child: Text( - detail.guidance!, - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark - ? DarkDesignTokens.text2 - : DesignTokens.text2, - ), - ), - ), - ], - ), - ], - if (detail.effect?.isNotEmpty ?? false) ...[ - const SizedBox(height: DesignTokens.space2), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '💪 功效: ', - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w500, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - Expanded( - child: Text( - detail.effect!, - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark - ? DarkDesignTokens.text2 - : DesignTokens.text2, - ), - ), - ), - ], - ), - ], - if (detail.usageTip.isNotEmpty) ...[ - const SizedBox(height: DesignTokens.space2), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '💡 技巧: ', - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w500, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - Expanded( - child: Text( - detail.usageTip.join('; '), - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark - ? DarkDesignTokens.text2 - : DesignTokens.text2, - ), - ), - ), - ], - ), - ], - if (detail.allergen.isNotEmpty) ...[ - const SizedBox(height: DesignTokens.space2), - Row( - children: [ - Text( - '⚠️ 过敏: ', - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w500, - color: DesignTokens.orange, - ), - ), - Expanded( - child: Text( - detail.allergen.join('、'), - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: DesignTokens.orange, - ), - ), - ), - ], - ), - ], - ], - ), - ); - } - - Widget _buildApiDetailCard(IngredientModel detail, bool isDark) { - return Container( - padding: EdgeInsets.all(DesignTokens.space3), - margin: EdgeInsets.only(bottom: DesignTokens.space3), - decoration: BoxDecoration( - color: (DesignTokens.dynamicPrimary).withValues(alpha: 0.08), - borderRadius: DesignTokens.borderRadiusMd, - border: Border.all( - color: (DesignTokens.dynamicPrimary).withValues(alpha: 0.2), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text('📖', style: TextStyle(fontSize: 16)), - const SizedBox(width: DesignTokens.space2), - Text( - '食材详情 (API数据)', - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - ], - ), - if (detail.introduction != null && - detail.introduction!.isNotEmpty) ...[ - const SizedBox(height: DesignTokens.space2), - Text( - detail.introduction!, - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - ], - if (detail.nutrition != null && detail.nutrition!.isNotEmpty) ...[ - const SizedBox(height: DesignTokens.space2), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '📊 营养: ', - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w500, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - Expanded( - child: Text( - detail.nutrition!, - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark - ? DarkDesignTokens.text2 - : DesignTokens.text2, - ), - ), - ), - ], - ), - ], - if (detail.guidance != null && detail.guidance!.isNotEmpty) ...[ - const SizedBox(height: DesignTokens.space2), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '🛒 选购: ', - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w500, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - Expanded( - child: Text( - detail.guidance!, - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark - ? DarkDesignTokens.text2 - : DesignTokens.text2, - ), - ), - ), - ], - ), - ], - if (detail.effect != null && detail.effect!.isNotEmpty) ...[ - const SizedBox(height: DesignTokens.space2), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '💪 功效: ', - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w500, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - Expanded( - child: Text( - detail.effect!, - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark - ? DarkDesignTokens.text2 - : DesignTokens.text2, - ), - ), - ), - ], - ), - ], - if (detail.allergen.isNotEmpty) ...[ - const SizedBox(height: DesignTokens.space2), - Row( - children: [ - Text( - '⚠️ 过敏: ', - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w500, - color: DesignTokens.orange, - ), - ), - Expanded( - child: Text( - detail.allergen.join('、'), - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: DesignTokens.orange, - ), - ), - ), - ], - ), - ], - ], - ), - ); - } - - Widget _buildDetailHeader( - String name, - String category, - IngredientNutritionData nutrition, - bool isDark, - ) { - return Container( - padding: const EdgeInsets.all(DesignTokens.space4), - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - borderRadius: DesignTokens.borderRadiusLg, - boxShadow: DesignTokens.shadowsSm, - ), - child: Column( - children: [ - Text( - _getIngredientEmoji(category), - style: const TextStyle(fontSize: 48), - ), - const SizedBox(height: DesignTokens.space2), - Text( - name, - style: TextStyle( - fontSize: DesignTokens.fontXxl, - fontWeight: FontWeight.w700, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - const SizedBox(height: DesignTokens.space1), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: DesignTokens.green.withValues(alpha: 0.1), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Text( - category, - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: DesignTokens.green, - ), - ), - ), - SizedBox(height: DesignTokens.space3), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${nutrition.calories.toInt()}', - style: TextStyle( - fontSize: 36, - fontWeight: FontWeight.w700, - color: DesignTokens.dynamicPrimary, - ), - ), - const SizedBox(width: 4), - Padding( - padding: const EdgeInsets.only(top: 12), - child: Text( - 'kcal/${nutrition.unit}', - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, - ), - ), - ), - ], - ), - ], - ), - ); - } - - Widget _buildCalorieOverview(IngredientNutritionData nutrition, bool isDark) { - return Container( - padding: const EdgeInsets.all(DesignTokens.space4), - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - borderRadius: DesignTokens.borderRadiusLg, - boxShadow: DesignTokens.shadowsSm, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text('📊', style: TextStyle(fontSize: 18)), - const SizedBox(width: DesignTokens.space2), - Text( - '营养概览', - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - ], - ), - const SizedBox(height: DesignTokens.space4), - Row( - children: [ - Expanded( - child: _buildMacroItem( - '🔥 热量', - '${nutrition.calories.toInt()}', - 'kcal', - DesignTokens.orange, - isDark, - ), - ), - Expanded( - child: _buildMacroItem( - '💪 蛋白质', - '${nutrition.protein.toInt()}', - 'g', - DesignTokens.red, - isDark, - ), - ), - Expanded( - child: _buildMacroItem( - '🧈 脂肪', - '${nutrition.fat.toInt()}', - 'g', - DesignTokens.secondary, - isDark, - ), - ), - ], - ), - const SizedBox(height: DesignTokens.space3), - Row( - children: [ - Expanded( - child: _buildMacroItem( - '🍞 碳水', - '${nutrition.carbs.toInt()}', - 'g', - DesignTokens.green, - isDark, - ), - ), - Expanded( - child: _buildMacroItem( - '🌾 纤维', - '${nutrition.fiber.toInt()}', - 'g', - DesignTokens.text2, - isDark, - ), - ), - const Expanded(child: SizedBox()), - ], - ), - ], - ), - ); - } - - Widget _buildMacroItem( - String label, - String value, - String unit, - Color color, - bool isDark, - ) { - return Column( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Center( - child: Text( - value, - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w700, - color: color, - ), - ), - ), - ), - const SizedBox(height: 4), - Text( - label, - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, - ), - ), - Text( - unit, - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, - ), - ), - ], - ); - } - - Widget _buildNutritionBars(IngredientNutritionData nutrition, bool isDark) { - final total = nutrition.protein + nutrition.fat + nutrition.carbs; - if (total == 0) return const SizedBox(); - - final proteinPct = total > 0 ? nutrition.protein / total : 0.0; - final fatPct = total > 0 ? nutrition.fat / total : 0.0; - final carbsPct = total > 0 ? nutrition.carbs / total : 0.0; - - return Container( - padding: const EdgeInsets.all(DesignTokens.space4), - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - borderRadius: DesignTokens.borderRadiusLg, - boxShadow: DesignTokens.shadowsSm, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text('🥧', style: TextStyle(fontSize: 18)), - const SizedBox(width: DesignTokens.space2), - Text( - '营养素占比', - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - ], - ), - const SizedBox(height: DesignTokens.space3), - ClipRRect( - borderRadius: DesignTokens.borderRadiusFull, - child: SizedBox( - height: 12, - child: Row( - children: [ - if (proteinPct > 0) - Expanded( - flex: (proteinPct * 100).round().clamp(1, 100), - child: Container(color: DesignTokens.red), - ), - if (fatPct > 0) - Expanded( - flex: (fatPct * 100).round().clamp(1, 100), - child: Container(color: DesignTokens.secondary), - ), - if (carbsPct > 0) - Expanded( - flex: (carbsPct * 100).round().clamp(1, 100), - child: Container(color: DesignTokens.green), - ), - ], - ), - ), - ), - const SizedBox(height: DesignTokens.space3), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _buildLegendItem( - '💪 蛋白质', - '${(proteinPct * 100).toStringAsFixed(0)}%', - DesignTokens.red, - isDark, - ), - _buildLegendItem( - '🧈 脂肪', - '${(fatPct * 100).toStringAsFixed(0)}%', - DesignTokens.secondary, - isDark, - ), - _buildLegendItem( - '🍞 碳水', - '${(carbsPct * 100).toStringAsFixed(0)}%', - DesignTokens.green, - isDark, - ), - ], - ), - ], - ), - ); - } - - Widget _buildLegendItem( - String label, - String value, - Color color, - bool isDark, - ) { - return Column( - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ), - const SizedBox(width: 4), - Text( - label, - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - ], - ), - const SizedBox(height: 2), - Text( - value, - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - ], - ); - } - - Widget _buildKeyNutrientsCard( - IngredientNutritionData nutrition, - bool isDark, - ) { - if (nutrition.keyNutrients.isEmpty) return const SizedBox(); - - return Container( - padding: const EdgeInsets.all(DesignTokens.space4), - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - borderRadius: DesignTokens.borderRadiusLg, - boxShadow: DesignTokens.shadowsSm, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text('✨', style: TextStyle(fontSize: 18)), - const SizedBox(width: DesignTokens.space2), - Text( - '关键营养素', - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - ], - ), - SizedBox(height: DesignTokens.space3), - Wrap( - spacing: DesignTokens.space2, - runSpacing: DesignTokens.space2, - children: nutrition.keyNutrients.map((nutrient) { - return Container( - padding: EdgeInsets.symmetric( - horizontal: DesignTokens.space3, - vertical: DesignTokens.space2, - ), - decoration: BoxDecoration( - color: (DesignTokens.dynamicPrimary).withValues(alpha: 0.1), - borderRadius: DesignTokens.borderRadiusFull, - ), - child: Text( - nutrient, - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w500, - color: DesignTokens.dynamicPrimary, - ), - ), - ); - }).toList(), - ), - ], - ), - ); - } - - Widget _buildSeasonCard(IngredientNutritionData nutrition, bool isDark) { - if (nutrition.season.isEmpty) return const SizedBox(); - - return Container( - padding: const EdgeInsets.all(DesignTokens.space4), - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - borderRadius: DesignTokens.borderRadiusLg, - boxShadow: DesignTokens.shadowsSm, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text('📅', style: TextStyle(fontSize: 18)), - const SizedBox(width: DesignTokens.space2), - Text( - '最佳时令', - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - ], - ), - const SizedBox(height: DesignTokens.space3), - Row( - children: [ - _buildSeasonChip('春', nutrition.season.contains('春'), isDark), - _buildSeasonChip('夏', nutrition.season.contains('夏'), isDark), - _buildSeasonChip('秋', nutrition.season.contains('秋'), isDark), - _buildSeasonChip('冬', nutrition.season.contains('冬'), isDark), - if (nutrition.season.contains('四季')) - _buildSeasonChip('四季', true, isDark), - ], - ), - ], - ), - ); - } - - Widget _buildSeasonChip(String label, bool active, bool isDark) { - return Container( - margin: const EdgeInsets.only(right: DesignTokens.space2), - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space3, - vertical: DesignTokens.space2, - ), - decoration: BoxDecoration( - color: active - ? DesignTokens.green.withValues(alpha: 0.15) - : isDark - ? DarkDesignTokens.segmentedBg - : DesignTokens.text3.withValues(alpha: 0.08), - borderRadius: DesignTokens.borderRadiusFull, - ), - child: Text( - label, - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: active ? FontWeight.w600 : FontWeight.normal, - color: active - ? DesignTokens.green - : isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, - ), - ), - ); - } - - Widget _buildPurchaseTipCard(IngredientNutritionData nutrition, bool isDark) { - if (nutrition.purchaseTip.isEmpty) return const SizedBox(); - - return Container( - padding: const EdgeInsets.all(DesignTokens.space4), - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - borderRadius: DesignTokens.borderRadiusLg, - boxShadow: DesignTokens.shadowsSm, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text('🛒', style: TextStyle(fontSize: 18)), - const SizedBox(width: DesignTokens.space2), - Text( - '选购技巧', - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - ], - ), - const SizedBox(height: DesignTokens.space3), - Container( - width: double.infinity, - padding: const EdgeInsets.all(DesignTokens.space3), - decoration: BoxDecoration( - color: DesignTokens.green.withValues(alpha: 0.08), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('✅', style: TextStyle(fontSize: 14)), - const SizedBox(width: DesignTokens.space2), - Expanded( - child: Text( - nutrition.purchaseTip, - style: TextStyle( - fontSize: DesignTokens.fontMd, - color: isDark - ? DarkDesignTokens.text2 - : DesignTokens.text2, - height: 1.5, - ), - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildStorageTipCard(IngredientNutritionData nutrition, bool isDark) { - if (nutrition.storageTip.isEmpty) return const SizedBox(); - - return Container( - padding: const EdgeInsets.all(DesignTokens.space4), - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - borderRadius: DesignTokens.borderRadiusLg, - boxShadow: DesignTokens.shadowsSm, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text('📦', style: TextStyle(fontSize: 18)), - const SizedBox(width: DesignTokens.space2), - Text( - '储存方法', - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - ], - ), - SizedBox(height: DesignTokens.space3), - Container( - width: double.infinity, - padding: EdgeInsets.all(DesignTokens.space3), - decoration: BoxDecoration( - color: (DesignTokens.dynamicPrimary).withValues(alpha: 0.08), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('💡', style: TextStyle(fontSize: 14)), - const SizedBox(width: DesignTokens.space2), - Expanded( - child: Text( - nutrition.storageTip, - style: TextStyle( - fontSize: DesignTokens.fontMd, - color: isDark - ? DarkDesignTokens.text2 - : DesignTokens.text2, - height: 1.5, - ), - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildSubstitutionSection(String name, bool isDark) { - final substitutions = _getSubstitutions(name); - if (substitutions.isEmpty) return const SizedBox(); - - return Container( - padding: const EdgeInsets.all(DesignTokens.space4), - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - borderRadius: DesignTokens.borderRadiusLg, - boxShadow: DesignTokens.shadowsSm, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text('🔄', style: TextStyle(fontSize: 18)), - const SizedBox(width: DesignTokens.space2), - Text( - '替代建议', - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - ], - ), - const SizedBox(height: DesignTokens.space3), - Wrap( - spacing: DesignTokens.space2, - runSpacing: DesignTokens.space2, - children: substitutions.map((sub) { - return GestureDetector( - onTap: () { - _searchController.text = sub; - _loadIngredientByName(sub, null); - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space3, - vertical: DesignTokens.space2, - ), - decoration: BoxDecoration( - color: DesignTokens.dynamicPrimary.withValues(alpha: 0.08), - borderRadius: DesignTokens.borderRadiusMd, - border: Border.all( - color: DesignTokens.dynamicPrimary.withValues(alpha: 0.2), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - sub, - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w500, - color: DesignTokens.dynamicPrimary, - ), - ), - const SizedBox(width: 4), - Icon( - CupertinoIcons.arrow_right, - size: 10, - color: DesignTokens.dynamicPrimary, - ), - ], - ), - ), - ); - }).toList(), - ), - ], - ), - ); - } - - List _getSubstitutions(String name) { - const subMap = { - '鸡肉': ['鸭肉', '兔肉', '豆腐'], - '鸡胸肉': ['火鸡胸肉', '瘦猪肉', '豆腐'], - '猪肉': ['牛肉', '羊肉', '鸡肉'], - '牛肉': ['猪肉', '羊肉', '鸡肉'], - '羊肉': ['牛肉', '猪肉', '鸡肉'], - '鱼': ['虾', '蟹', '豆腐'], - '虾': ['蟹', '鱼', '干贝'], - '蟹': ['虾', '鱼', '干贝'], - '鸡蛋': ['鹌鹑蛋', '豆腐', '亚麻籽蛋'], - '牛奶': ['豆浆', '燕麦奶', '椰奶'], - '黄油': ['椰子油', '橄榄油', '花生酱'], - '面粉': ['米粉', '杏仁粉', '椰子粉'], - '白糖': ['蜂蜜', '枫糖浆', '甜菊糖'], - '大豆': ['鹰嘴豆', '扁豆', '花生'], - '花生': ['杏仁', '腰果', '葵花籽'], - '番茄': ['红椒', '南瓜', '胡萝卜'], - '土豆': ['红薯', '山药', '芋头'], - '大米': ['小米', '藜麦', '糙米'], - '洋葱': ['大葱', '韭菜', '蒜苗'], - '大蒜': ['洋葱', '蒜粉', '韭菜'], - '生姜': ['姜粉', '沙姜', '白胡椒'], - '辣椒': ['黑胡椒', '花椒', '芥末'], - '豆腐': ['鸡蛋', '鸡肉', '鱼肉'], - '香菇': ['平菇', '金针菇', '杏鲍菇'], - '胡萝卜': ['南瓜', '红薯', '红椒'], - '西兰花': ['花菜', '羽衣甘蓝', '菠菜'], - '菠菜': ['油菜', '生菜', '羽衣甘蓝'], - '苹果': ['梨', '桃子', '香蕉'], - '香蕉': ['苹果', '牛油果', '芒果'], - }; - - for (final entry in subMap.entries) { - if (name.contains(entry.key) || entry.key.contains(name)) { - return entry.value; - } - } - return []; - } - - Widget _buildSimilarRecipesSection(String ingredientName, bool isDark) { - return Container( - padding: const EdgeInsets.all(DesignTokens.space4), - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - borderRadius: DesignTokens.borderRadiusLg, - boxShadow: DesignTokens.shadowsSm, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text('🍽️', style: TextStyle(fontSize: 18)), - const SizedBox(width: DesignTokens.space2), - Expanded( - child: Text( - '含「$ingredientName」的菜谱', - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - ), - if (_isLoadingSimilar) - const CupertinoActivityIndicator(radius: 8), - ], - ), - const SizedBox(height: DesignTokens.space3), - ..._similarRecipes - .take(4) - .map((recipe) => _buildSimilarRecipeItem(recipe, isDark)), - ], - ), - ); - } - - Widget _buildSimilarRecipeItem(RecipeModel recipe, bool isDark) { - return GestureDetector( - onTap: () => Get.toNamed('/recipe-detail', arguments: '${recipe.id}'), - child: Container( - margin: const EdgeInsets.only(bottom: DesignTokens.space2), - padding: const EdgeInsets.all(DesignTokens.space3), - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.background : DesignTokens.background, - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: DesignTokens.dynamicPrimary.withValues(alpha: 0.08), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Center( - child: Icon( - CupertinoIcons.star_fill, - color: DesignTokens.dynamicPrimary, - size: 20, - ), - ), - ), - const SizedBox(width: DesignTokens.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - recipe.title, - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w500, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (recipe.nutrition?.calories != null) - Text( - '🔥 ${recipe.nutrition!.calories!.toInt()}千卡', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, - ), - ), - ], - ), - ), - Icon( - CupertinoIcons.chevron_right, - size: 14, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, - ), - ], - ), - ), - ); - } - - String _getIngredientEmoji(String category) { - switch (category) { - case '肉类': - return '🥩'; - case '蔬菜': - return '🥬'; - case '调料': - return '🧂'; - case '主食': - return '🍚'; - case '蛋奶豆': - return '🥚'; - case '水果': - return '🍎'; - case '坚果': - return '🥜'; - default: - return '🥕'; - } - } - - @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } -} diff --git a/lib/src/pages/tools/tool_item_detail_page.dart b/lib/src/pages/tools/tool_item_detail_page.dart new file mode 100644 index 0000000..afa9f33 --- /dev/null +++ b/lib/src/pages/tools/tool_item_detail_page.dart @@ -0,0 +1,317 @@ +/* + * 文件: tool_item_detail_page.dart + * 名称: 工具项详情页 + * 作用: 从工具中心进入,展示 ToolItem 详细信息和功能说明 + * 创建: 2026-04-19 从 tools_center_page.dart 拆分 + * 更新: 2026-04-19 初始拆分 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/models/tool_item_model.dart'; + +class ToolItemDetailPage extends StatelessWidget { + final ToolItem tool; + + const ToolItemDetailPage({super.key, required this.tool}); + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return CupertinoPageScaffold( + backgroundColor: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + child: SafeArea( + child: Column( + children: [ + _buildHeader(context, isDark), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Column( + children: [ + _buildHeroCard(isDark), + const SizedBox(height: DesignTokens.space4), + _buildInfoCards(isDark), + ], + ), + ), + ), + _buildBottomButton(isDark), + ], + ), + ), + ); + } + + Widget _buildHeader(BuildContext context, bool isDark) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.text3.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Icon( + CupertinoIcons.back, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Text( + tool.name, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + ], + ), + ); + } + + Widget _buildHeroCard(bool isDark) { + return Container( + width: double.infinity, + padding: EdgeInsets.all(DesignTokens.space5), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + DesignTokens.dynamicPrimary.withValues(alpha: 0.15), + DesignTokens.secondary.withValues(alpha: 0.08), + ], + ), + borderRadius: DesignTokens.borderRadiusXl, + border: Border.all( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + ), + ), + child: Column( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusXl, + ), + child: Center( + child: Text(tool.icon, style: const TextStyle(fontSize: 40)), + ), + ), + const SizedBox(height: DesignTokens.space4), + Text( + tool.name, + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + if (tool.description != null) ...[ + const SizedBox(height: DesignTokens.space2), + Text( + tool.description!, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + height: 1.5, + ), + ), + ], + SizedBox(height: DesignTokens.space4), + Container( + padding: EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: tool.needsNetwork + ? DesignTokens.green.withValues(alpha: 0.15) + : DesignTokens.dynamicPrimary.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusLg, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + tool.needsNetwork + ? CupertinoIcons.wifi + : CupertinoIcons.device_phone_portrait, + size: 14, + color: tool.needsNetwork + ? DesignTokens.green + : DesignTokens.dynamicPrimary, + ), + SizedBox(width: 6), + Text( + tool.needsNetwork ? '需要网络连接' : '本地运行', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: tool.needsNetwork + ? DesignTokens.green + : DesignTokens.dynamicPrimary, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildInfoCards(bool isDark) { + return Column( + children: [ + _buildInfoCard( + icon: CupertinoIcons.chart_bar, + title: '使用统计', + value: '已使用 ${tool.usageCount} 次', + isDark: isDark, + ), + const SizedBox(height: DesignTokens.space2), + _buildInfoCard( + icon: CupertinoIcons.folder, + title: '所属分类', + value: _getCategoryName(tool.category), + isDark: isDark, + ), + ], + ); + } + + Widget _buildInfoCard({ + required IconData icon, + required String title, + required String value, + required bool isDark, + }) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + ), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Icon(icon, size: 20, color: DesignTokens.dynamicPrimary), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildBottomButton(bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + border: Border( + top: BorderSide( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + ), + ), + ), + child: SizedBox( + width: double.infinity, + child: CupertinoButton( + padding: EdgeInsets.symmetric(vertical: DesignTokens.space3), + borderRadius: DesignTokens.borderRadiusMd, + color: DesignTokens.dynamicPrimary, + onPressed: () { + Get.toNamed(tool.route); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + CupertinoIcons.arrow_right_circle, + size: 18, + color: CupertinoColors.white, + ), + const SizedBox(width: 8), + Text( + '进入工具', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: CupertinoColors.white, + ), + ), + ], + ), + ), + ), + ); + } + + String _getCategoryName(String categoryId) { + const categoryNames = { + 'cooking': '烹饪助手', + 'health': '健康营养', + 'data': '数据查询', + 'planning': '规划管理', + }; + return categoryNames[categoryId] ?? categoryId; + } +} diff --git a/lib/src/pages/tools/tools_category_widgets.dart b/lib/src/pages/tools/tools_category_widgets.dart new file mode 100644 index 0000000..9de513b --- /dev/null +++ b/lib/src/pages/tools/tools_category_widgets.dart @@ -0,0 +1,397 @@ +/* + * 文件: tools_category_widgets.dart + * 名称: 工具中心分类组件 + * 作用: 分类组、分类头部、工具卡片等组件 + * 创建: 2026-04-19 从 tools_center_page.dart 拆分 + * 更新: 2026-04-19 初始拆分 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/models/tool_item_model.dart'; + +class ToolsCategoryGroup extends StatelessWidget { + final String category; + final List tools; + final Map categoryInfo; + final bool isDark; + final ValueChanged onOpenTool; + final ValueChanged onUseTool; + + const ToolsCategoryGroup({ + super.key, + required this.category, + required this.tools, + required this.categoryInfo, + required this.isDark, + required this.onOpenTool, + required this.onUseTool, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: [ + BoxShadow( + color: isDark + ? Colors.black.withValues(alpha: 0.3) + : Colors.black.withValues(alpha: 0.05), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ToolsCategoryHeader( + categoryInfo: categoryInfo, + count: tools.length, + isDark: isDark, + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: LayoutBuilder( + builder: (context, constraints) { + final crossCount = _calcCrossAxisCount(constraints.maxWidth); + final itemWidth = + (constraints.maxWidth - 12 * (crossCount - 1)) / crossCount; + + return Wrap( + spacing: 12, + runSpacing: 12, + children: tools.map((tool) { + return SizedBox( + width: itemWidth, + child: ToolsCategoryChip( + tool: tool, + categoryInfo: categoryInfo, + isDark: isDark, + onOpenTool: onOpenTool, + onUseTool: onUseTool, + ), + ); + }).toList(), + ); + }, + ), + ), + ], + ), + ); + } + + int _calcCrossAxisCount(double width) { + if (width >= 1200) return 4; + if (width >= 900) return 3; + if (width >= 600) return 2; + return 2; + } +} + +class ToolsCategoryHeader extends StatelessWidget { + final Map categoryInfo; + final int count; + final bool isDark; + + const ToolsCategoryHeader({ + super.key, + required this.categoryInfo, + required this.count, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + final gradientColors = categoryInfo['gradient'] as List; + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + gradientColors[0].withValues(alpha: 0.15), + gradientColors[1].withValues(alpha: 0.08), + ], + ), + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: gradientColors, + ), + borderRadius: DesignTokens.borderRadiusMd, + boxShadow: [ + BoxShadow( + color: gradientColors[0].withValues(alpha: 0.4), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Center( + child: Text( + categoryInfo['icon'] as String, + style: const TextStyle(fontSize: 20), + ), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + categoryInfo['name'] as String, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + Text( + '$count 个工具', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class ToolsCategoryChip extends StatelessWidget { + final ToolItem tool; + final Map categoryInfo; + final bool isDark; + final ValueChanged onOpenTool; + final ValueChanged onUseTool; + + const ToolsCategoryChip({ + super.key, + required this.tool, + required this.categoryInfo, + required this.isDark, + required this.onOpenTool, + required this.onUseTool, + }); + + @override + Widget build(BuildContext context) { + final gradientColors = categoryInfo['gradient'] as List; + + return Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.text3.withValues(alpha: 0.04), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + gradientColors[0].withValues(alpha: 0.2), + gradientColors[1].withValues(alpha: 0.1), + ], + ), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Text(tool.icon, style: const TextStyle(fontSize: 22)), + ), + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tool.name, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 2), + Row( + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: tool.needsNetwork + ? DesignTokens.green + : DesignTokens.dynamicPrimary, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text( + tool.needsNetwork ? '联网' : '本地', + style: TextStyle( + fontSize: 10, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + GestureDetector( + onTap: () => onOpenTool(tool), + child: Container( + width: double.infinity, + height: 32, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: gradientColors, + ), + borderRadius: DesignTokens.borderRadiusMd, + boxShadow: [ + BoxShadow( + color: gradientColors[0].withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.arrow_right_circle, + size: 16, + color: CupertinoColors.white, + ), + const SizedBox(width: 6), + Text( + '工具详情', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: CupertinoColors.white, + ), + ), + ], + ), + ), + ), + const SizedBox(height: DesignTokens.space2), + GestureDetector( + onTap: () => onUseTool(tool), + child: Container( + width: double.infinity, + height: 32, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.9), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: gradientColors[0].withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.play_circle, + size: 16, + color: gradientColors[0], + ), + const SizedBox(width: 6), + Text( + '使用工具', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: gradientColors[0], + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +Map getToolsCategoryInfo(String categoryId) { + final categoryMap = { + 'cooking': { + 'name': '烹饪助手', + 'icon': '🍳', + 'gradient': [const Color(0xFFFF6B35), const Color(0xFFFF9F1C)], + }, + 'health': { + 'name': '健康营养', + 'icon': '💊', + 'gradient': [const Color(0xFF2ECC71), const Color(0xFF27AE60)], + }, + 'data': { + 'name': '数据查询', + 'icon': '📊', + 'gradient': [DesignTokens.blue, const Color(0xFF2980B9)], + }, + 'planning': { + 'name': '规划管理', + 'icon': '📅', + 'gradient': [const Color(0xFF9B59B6), const Color(0xFF8E44AD)], + }, + }; + return categoryMap[categoryId] ?? + { + 'name': categoryId, + 'icon': '📦', + 'gradient': [DesignTokens.dynamicPrimary, DesignTokens.secondary], + }; +} diff --git a/lib/src/pages/tools/tools_center_page.dart b/lib/src/pages/tools/tools_center_page.dart index 9d01dfd..d92cade 100644 --- a/lib/src/pages/tools/tools_center_page.dart +++ b/lib/src/pages/tools/tools_center_page.dart @@ -3,14 +3,18 @@ * 名称: 工具中心页面 * 作用: 展示所有工具,支持分类筛选和搜索 * 更新: 2026-04-14 新增embedded模式(嵌入面板时隐藏导航栏); 布局改为一行2列响应式 + * 更新: 2026-04-19 新增精选推荐/最近使用区域,扩展页面内容 + * 更新: 2026-04-19 拆分组件到独立文件(tools_featured_section/tools_category_widgets/tool_item_detail_page) */ import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; import 'package:mom_kitchen/src/controllers/tools/tools_controller.dart'; import 'package:mom_kitchen/src/models/tool_item_model.dart'; +import 'package:mom_kitchen/src/pages/tools/tools_category_widgets.dart'; +import 'package:mom_kitchen/src/pages/tools/tools_featured_section.dart'; +import 'package:mom_kitchen/src/pages/tools/tool_item_detail_page.dart'; class ToolsCenterPage extends StatefulWidget { final bool embedded; @@ -49,7 +53,6 @@ class _ToolsCenterPageState extends State if (!_isInitialized) { _isInitialized = true; _initController(); - _animationController.forward(); } } @@ -101,16 +104,102 @@ class _ToolsCenterPageState extends State final filteredTools = _controller!.filteredTools; final groups = _groupToolsByCategory(filteredTools); final categories = groups.keys.toList(); + final allTools = _controller!.tools; + final isSearching = _searchQuery.isNotEmpty; return CustomScrollView( physics: const BouncingScrollPhysics(), slivers: [ + if (!isSearching && filteredTools.isNotEmpty) ...[ + SliverToBoxAdapter( + child: AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + final slideAnimation = + Tween( + begin: const Offset(0, 0.2), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: _animationController, + curve: const Interval(0.0, 0.4, curve: Curves.easeOut), + ), + ); + return SlideTransition( + position: slideAnimation, + child: FadeTransition( + opacity: CurvedAnimation( + parent: _animationController, + curve: const Interval(0.0, 0.4, curve: Curves.easeOut), + ), + child: child, + ), + ); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB( + DesignTokens.space4, + DesignTokens.space3, + DesignTokens.space4, + DesignTokens.space2, + ), + child: ToolsFeaturedSection( + allTools: allTools, + onToolTap: _useTool, + ), + ), + ), + ), + SliverToBoxAdapter( + child: AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + final slideAnimation = + Tween( + begin: const Offset(0, 0.2), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: _animationController, + curve: const Interval( + 0.05, + 0.45, + curve: Curves.easeOut, + ), + ), + ); + return SlideTransition( + position: slideAnimation, + child: FadeTransition( + opacity: CurvedAnimation( + parent: _animationController, + curve: const Interval(0.05, 0.45, curve: Curves.easeOut), + ), + child: child, + ), + ); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB( + DesignTokens.space4, + 0, + DesignTokens.space4, + DesignTokens.space2, + ), + child: ToolsRecentSection( + allTools: allTools, + onToolTap: _useTool, + ), + ), + ), + ), + ], if (filteredTools.isNotEmpty) ...categories.asMap().entries.map((entry) { final groupIndex = entry.key; final category = entry.value; final tools = groups[category]!; - final categoryInfo = _getCategoryInfo(category); + final categoryInfo = getToolsCategoryInfo(category); return SliverToBoxAdapter( child: AnimatedBuilder( animation: _animationController, @@ -141,11 +230,13 @@ class _ToolsCenterPageState extends State right: DesignTokens.space4, bottom: DesignTokens.space3, ), - child: _buildCategoryGroup( - category, - tools, - categoryInfo, - isDark, + child: ToolsCategoryGroup( + category: category, + tools: tools, + categoryInfo: categoryInfo, + isDark: isDark, + onOpenTool: _openTool, + onUseTool: _useTool, ), ), ), @@ -315,342 +406,6 @@ class _ToolsCenterPageState extends State return groups; } - Map _getCategoryInfo(String categoryId) { - final categoryMap = { - 'cooking': { - 'name': '烹饪助手', - 'icon': '🍳', - 'gradient': [const Color(0xFFFF6B35), const Color(0xFFFF9F1C)], - }, - 'health': { - 'name': '健康营养', - 'icon': '💊', - 'gradient': [const Color(0xFF2ECC71), const Color(0xFF27AE60)], - }, - 'data': { - 'name': '数据查询', - 'icon': '📊', - 'gradient': [DesignTokens.blue, Color(0xFF2980B9)], - }, - 'planning': { - 'name': '规划管理', - 'icon': '📅', - 'gradient': [Color(0xFF9B59B6), Color(0xFF8E44AD)], - }, - }; - return categoryMap[categoryId] ?? - { - 'name': categoryId, - 'icon': '📦', - 'gradient': [DesignTokens.dynamicPrimary, DesignTokens.secondary], - }; - } - - Widget _buildCategoryGroup( - String category, - List tools, - Map categoryInfo, - bool isDark, - ) { - return Container( - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - borderRadius: DesignTokens.borderRadiusLg, - boxShadow: [ - BoxShadow( - color: isDark - ? Colors.black.withValues(alpha: 0.3) - : Colors.black.withValues(alpha: 0.05), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildCategoryHeader(categoryInfo, tools.length, isDark), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: LayoutBuilder( - builder: (context, constraints) { - final crossCount = _calcCrossAxisCount(constraints.maxWidth); - final itemWidth = - (constraints.maxWidth - 12 * (crossCount - 1)) / crossCount; - - return Wrap( - spacing: 12, - runSpacing: 12, - children: tools.map((tool) { - return SizedBox( - width: itemWidth, - child: _buildToolChip(tool, categoryInfo, isDark), - ); - }).toList(), - ); - }, - ), - ), - ], - ), - ); - } - - int _calcCrossAxisCount(double width) { - if (width >= 1200) return 4; - if (width >= 900) return 3; - if (width >= 600) return 2; - return 2; - } - - Widget _buildCategoryHeader( - Map categoryInfo, - int count, - bool isDark, - ) { - final gradientColors = categoryInfo['gradient'] as List; - - return Container( - padding: const EdgeInsets.all(DesignTokens.space4), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - gradientColors[0].withValues(alpha: 0.15), - gradientColors[1].withValues(alpha: 0.08), - ], - ), - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: gradientColors, - ), - borderRadius: DesignTokens.borderRadiusMd, - boxShadow: [ - BoxShadow( - color: gradientColors[0].withValues(alpha: 0.4), - blurRadius: 8, - offset: const Offset(0, 4), - ), - ], - ), - child: Center( - child: Text( - categoryInfo['icon'] as String, - style: const TextStyle(fontSize: 20), - ), - ), - ), - const SizedBox(width: DesignTokens.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - categoryInfo['name'] as String, - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - Text( - '$count 个工具', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildToolChip( - ToolItem tool, - Map categoryInfo, - bool isDark, - ) { - final gradientColors = categoryInfo['gradient'] as List; - - return Container( - padding: const EdgeInsets.all(DesignTokens.space3), - decoration: BoxDecoration( - color: isDark - ? DarkDesignTokens.glass - : DesignTokens.text3.withValues(alpha: 0.04), - borderRadius: DesignTokens.borderRadiusMd, - border: Border.all( - color: isDark - ? DarkDesignTokens.glassBorder - : DesignTokens.text3.withValues(alpha: 0.08), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - gradientColors[0].withValues(alpha: 0.2), - gradientColors[1].withValues(alpha: 0.1), - ], - ), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Center( - child: Text(tool.icon, style: const TextStyle(fontSize: 22)), - ), - ), - const SizedBox(width: DesignTokens.space2), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - tool.name, - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w600, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - SizedBox(height: 2), - Row( - children: [ - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - color: tool.needsNetwork - ? DesignTokens.green - : DesignTokens.dynamicPrimary, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 4), - Text( - tool.needsNetwork ? '联网' : '本地', - style: TextStyle( - fontSize: 10, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, - ), - ), - ], - ), - ], - ), - ), - ], - ), - const SizedBox(height: DesignTokens.space2), - GestureDetector( - onTap: () => _openTool(tool), - child: Container( - width: double.infinity, - height: 32, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: gradientColors, - ), - borderRadius: DesignTokens.borderRadiusMd, - boxShadow: [ - BoxShadow( - color: gradientColors[0].withValues(alpha: 0.3), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - CupertinoIcons.arrow_right_circle, - size: 16, - color: CupertinoColors.white, - ), - const SizedBox(width: 6), - Text( - '工具详情', - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w600, - color: CupertinoColors.white, - ), - ), - ], - ), - ), - ), - const SizedBox(height: DesignTokens.space2), - GestureDetector( - onTap: () => _useTool(tool), - child: Container( - width: double.infinity, - height: 32, - decoration: BoxDecoration( - color: isDark - ? DarkDesignTokens.glass - : DesignTokens.card.withValues(alpha: 0.9), - borderRadius: DesignTokens.borderRadiusMd, - border: Border.all( - color: gradientColors[0].withValues(alpha: 0.3), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - CupertinoIcons.play_circle, - size: 16, - color: gradientColors[0], - ), - const SizedBox(width: 6), - Text( - '使用工具', - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w600, - color: gradientColors[0], - ), - ), - ], - ), - ), - ), - ], - ), - ); - } - Widget _buildEmptyState(bool isDark) { return Center( child: Column( @@ -692,7 +447,7 @@ class _ToolsCenterPageState extends State void _openTool(ToolItem tool) { _controller?.recordUsage(tool.id); Navigator.of(context).push( - CupertinoPageRoute(builder: (context) => _ToolDetailPage(tool: tool)), + CupertinoPageRoute(builder: (context) => ToolItemDetailPage(tool: tool)), ); } @@ -702,313 +457,10 @@ class _ToolsCenterPageState extends State Get.toNamed(tool.route); } else { Navigator.of(context).push( - CupertinoPageRoute(builder: (context) => _ToolDetailPage(tool: tool)), + CupertinoPageRoute( + builder: (context) => ToolItemDetailPage(tool: tool), + ), ); } } } - -class _ToolDetailPage extends StatelessWidget { - final ToolItem tool; - - const _ToolDetailPage({required this.tool}); - - @override - Widget build(BuildContext context) { - final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; - - return CupertinoPageScaffold( - backgroundColor: isDark - ? DarkDesignTokens.background - : DesignTokens.background, - child: SafeArea( - child: Column( - children: [ - _buildHeader(context, isDark), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(DesignTokens.space4), - child: Column( - children: [ - _buildHeroCard(isDark), - const SizedBox(height: DesignTokens.space4), - _buildInfoCards(isDark), - ], - ), - ), - ), - _buildBottomButton(isDark), - ], - ), - ), - ); - } - - Widget _buildHeader(BuildContext context, bool isDark) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - vertical: DesignTokens.space2, - ), - child: Row( - children: [ - GestureDetector( - onTap: () => Navigator.of(context).pop(), - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: isDark - ? DarkDesignTokens.glass - : DesignTokens.text3.withValues(alpha: 0.08), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Icon( - CupertinoIcons.back, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - ), - const SizedBox(width: DesignTokens.space3), - Expanded( - child: Text( - tool.name, - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - ), - ], - ), - ); - } - - Widget _buildHeroCard(bool isDark) { - return Container( - width: double.infinity, - padding: EdgeInsets.all(DesignTokens.space5), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - DesignTokens.dynamicPrimary.withValues(alpha: 0.15), - DesignTokens.secondary.withValues(alpha: 0.08), - ], - ), - borderRadius: DesignTokens.borderRadiusXl, - border: Border.all( - color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), - ), - ), - child: Column( - children: [ - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: DesignTokens.dynamicPrimary.withValues(alpha: 0.15), - borderRadius: DesignTokens.borderRadiusXl, - ), - child: Center( - child: Text(tool.icon, style: const TextStyle(fontSize: 40)), - ), - ), - const SizedBox(height: DesignTokens.space4), - Text( - tool.name, - style: TextStyle( - fontSize: DesignTokens.fontXxl, - fontWeight: FontWeight.w700, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - if (tool.description != null) ...[ - const SizedBox(height: DesignTokens.space2), - Text( - tool.description!, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: DesignTokens.fontMd, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - height: 1.5, - ), - ), - ], - SizedBox(height: DesignTokens.space4), - Container( - padding: EdgeInsets.symmetric( - horizontal: DesignTokens.space3, - vertical: DesignTokens.space2, - ), - decoration: BoxDecoration( - color: tool.needsNetwork - ? DesignTokens.green.withValues(alpha: 0.15) - : DesignTokens.dynamicPrimary.withValues(alpha: 0.15), - borderRadius: DesignTokens.borderRadiusLg, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - tool.needsNetwork - ? CupertinoIcons.wifi - : CupertinoIcons.device_phone_portrait, - size: 14, - color: tool.needsNetwork - ? DesignTokens.green - : DesignTokens.dynamicPrimary, - ), - SizedBox(width: 6), - Text( - tool.needsNetwork ? '需要网络连接' : '本地运行', - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w500, - color: tool.needsNetwork - ? DesignTokens.green - : DesignTokens.dynamicPrimary, - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildInfoCards(bool isDark) { - return Column( - children: [ - _buildInfoCard( - icon: CupertinoIcons.chart_bar, - title: '使用统计', - value: '已使用 ${tool.usageCount} 次', - isDark: isDark, - ), - const SizedBox(height: DesignTokens.space2), - _buildInfoCard( - icon: CupertinoIcons.folder, - title: '所属分类', - value: _getCategoryName(tool.category), - isDark: isDark, - ), - ], - ); - } - - Widget _buildInfoCard({ - required IconData icon, - required String title, - required String value, - required bool isDark, - }) { - return Container( - padding: const EdgeInsets.all(DesignTokens.space4), - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - borderRadius: DesignTokens.borderRadiusMd, - border: Border.all( - color: isDark - ? DarkDesignTokens.glassBorder - : DesignTokens.text3.withValues(alpha: 0.08), - ), - ), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Icon(icon, size: 20, color: DesignTokens.dynamicPrimary), - ), - const SizedBox(width: DesignTokens.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, - ), - ), - Text( - value, - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildBottomButton(bool isDark) { - return Container( - padding: const EdgeInsets.all(DesignTokens.space4), - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - border: Border( - top: BorderSide( - color: isDark - ? DarkDesignTokens.glassBorder - : DesignTokens.text3.withValues(alpha: 0.08), - ), - ), - ), - child: SizedBox( - width: double.infinity, - child: CupertinoButton( - padding: EdgeInsets.symmetric(vertical: DesignTokens.space3), - borderRadius: DesignTokens.borderRadiusMd, - color: DesignTokens.dynamicPrimary, - onPressed: () { - Get.toNamed(tool.route); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - CupertinoIcons.arrow_right_circle, - size: 18, - color: CupertinoColors.white, - ), - const SizedBox(width: 8), - Text( - '进入工具', - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - color: CupertinoColors.white, - ), - ), - ], - ), - ), - ), - ); - } - - String _getCategoryName(String categoryId) { - const categoryNames = { - 'cooking': '烹饪助手', - 'health': '健康营养', - 'data': '数据查询', - 'planning': '规划管理', - }; - return categoryNames[categoryId] ?? categoryId; - } -} diff --git a/lib/src/pages/tools/tools_featured_section.dart b/lib/src/pages/tools/tools_featured_section.dart new file mode 100644 index 0000000..e41b13c --- /dev/null +++ b/lib/src/pages/tools/tools_featured_section.dart @@ -0,0 +1,306 @@ +/* + * 文件: tools_featured_section.dart + * 名称: 工具中心精选推荐与最近使用组件 + * 作用: 展示精选推荐横向卡片和最近使用工具网格 + * 创建: 2026-04-19 从 tools_center_page.dart 拆分 + * 更新: 2026-04-19 初始拆分 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/models/tool_item_model.dart'; + +class ToolsFeaturedSection extends StatelessWidget { + final List allTools; + final ValueChanged onToolTap; + + const ToolsFeaturedSection({ + super.key, + required this.allTools, + required this.onToolTap, + }); + + static const List _featuredIds = [ + 'decision_maker', + 'farm_game', + 'cooking_timer', + 'order_assistant', + 'meal_planner', + ]; + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final featuredTools = _featuredIds + .map((id) => allTools.where((t) => t.id == id).firstOrNull) + .whereType() + .toList(); + + if (featuredTools.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('⭐', style: TextStyle(fontSize: 16)), + const SizedBox(width: DesignTokens.space2), + Text( + '精选推荐', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const Spacer(), + Text( + '滑动查看 →', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + SizedBox( + height: 120, + child: ListView.separated( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + itemCount: featuredTools.length, + separatorBuilder: (_, _) => + const SizedBox(width: DesignTokens.space3), + itemBuilder: (context, index) { + return _FeaturedCard( + tool: featuredTools[index], + isDark: isDark, + onTap: onToolTap, + ); + }, + ), + ), + ], + ); + } +} + +class _FeaturedCard extends StatelessWidget { + final ToolItem tool; + final bool isDark; + final ValueChanged onTap; + + const _FeaturedCard({ + required this.tool, + required this.isDark, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final gradientColors = DesignTokens.toolGradients; + final colorIndex = tool.id.hashCode % gradientColors.length; + final gradientColor = gradientColors[colorIndex]; + + return GestureDetector( + onTap: () => onTap(tool), + child: Container( + width: 150, + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + gradientColor.withValues(alpha: 0.15), + gradientColor.withValues(alpha: 0.05), + ], + ), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all(color: gradientColor.withValues(alpha: 0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: gradientColor.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Text( + tool.icon, + style: const TextStyle(fontSize: 18), + ), + ), + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tool.name, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (tool.waterfallSlot.badge != null) + Container( + margin: const EdgeInsets.only(top: 2), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: DesignTokens.red.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + tool.waterfallSlot.badge!, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: DesignTokens.red, + ), + ), + ), + ], + ), + ), + ], + ), + const Spacer(), + Text( + tool.description ?? '', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } +} + +class ToolsRecentSection extends StatelessWidget { + final List allTools; + final ValueChanged onToolTap; + + const ToolsRecentSection({ + super.key, + required this.allTools, + required this.onToolTap, + }); + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final recentTools = List.from(allTools) + ..sort((a, b) => b.usageCount.compareTo(a.usageCount)); + final topRecent = recentTools + .where((t) => t.usageCount > 0) + .take(4) + .toList(); + + if (topRecent.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('🕐', style: TextStyle(fontSize: 16)), + const SizedBox(width: DesignTokens.space2), + Text( + '最近使用', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const Spacer(), + Text( + '使用 ${topRecent.length} 个', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Row( + children: topRecent.map((tool) { + return Expanded( + child: GestureDetector( + onTap: () => onToolTap(tool), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 3), + padding: const EdgeInsets.symmetric( + vertical: DesignTokens.space3, + horizontal: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + ), + ), + child: Column( + children: [ + Text(tool.icon, style: const TextStyle(fontSize: 24)), + const SizedBox(height: DesignTokens.space1), + Text( + tool.name, + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + '${tool.usageCount}次', + style: TextStyle( + fontSize: 9, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + ), + ); + }).toList(), + ), + ], + ); + } +} diff --git a/lib/src/services/tools/order_api_service.dart b/lib/src/services/api/order_api_service.dart similarity index 100% rename from lib/src/services/tools/order_api_service.dart rename to lib/src/services/api/order_api_service.dart diff --git a/lib/src/services/core/app_info_service.dart b/lib/src/services/core/app_info_service.dart index 3a5affb..65573b5 100644 --- a/lib/src/services/core/app_info_service.dart +++ b/lib/src/services/core/app_info_service.dart @@ -17,8 +17,7 @@ class AppInfoService { String get appName => _packageInfo?.appName ?? 'Mom\'s Kitchen'; // 获取包名 - String get packageName => - _packageInfo?.packageName ?? 'com.example.mom_kitchen'; + String get packageName => _packageInfo?.packageName ?? 'cute.major.kitchen'; // 获取版本号 (如 1.0.0) String get version => _packageInfo?.version ?? '1.0.0'; diff --git a/lib/src/services/user/taste_preference_service.dart b/lib/src/services/core/taste_preference_service.dart similarity index 100% rename from lib/src/services/user/taste_preference_service.dart rename to lib/src/services/core/taste_preference_service.dart diff --git a/lib/src/services/data/data_export_service.dart b/lib/src/services/data/business/data_export_service.dart similarity index 100% rename from lib/src/services/data/data_export_service.dart rename to lib/src/services/data/business/data_export_service.dart diff --git a/lib/src/services/data/email_service.dart b/lib/src/services/data/business/email_service.dart similarity index 100% rename from lib/src/services/data/email_service.dart rename to lib/src/services/data/business/email_service.dart diff --git a/lib/src/services/data/ingredient_nutrition_db.dart b/lib/src/services/data/business/ingredient_nutrition_db.dart similarity index 100% rename from lib/src/services/data/ingredient_nutrition_db.dart rename to lib/src/services/data/business/ingredient_nutrition_db.dart diff --git a/lib/src/services/data/mini_card_service.dart b/lib/src/services/data/business/mini_card_service.dart similarity index 100% rename from lib/src/services/data/mini_card_service.dart rename to lib/src/services/data/business/mini_card_service.dart diff --git a/lib/src/services/data/recipe_share_service.dart b/lib/src/services/data/business/recipe_share_service.dart similarity index 94% rename from lib/src/services/data/recipe_share_service.dart rename to lib/src/services/data/business/recipe_share_service.dart index 384fb74..564413e 100644 --- a/lib/src/services/data/recipe_share_service.dart +++ b/lib/src/services/data/business/recipe_share_service.dart @@ -4,6 +4,7 @@ * 作用: 将菜谱数据推送到 recipe_share.php 本地存储,供扫码后展示 * 创建: 2026-04-18 * 更新: 2026-04-18 使用 toJson() 传输完整菜谱数据 + * 最后更新: 2026-04-18 删除接口改为 RESTful DELETE 方法 */ import 'package:dio/dio.dart'; @@ -76,12 +77,12 @@ class RecipeShareService { return false; } - /// 删除菜谱分享数据 + /// 删除菜谱分享数据 (RESTful DELETE) Future deleteRecipeShare(int recipeId) async { try { - final response = await _dio.get( + final response = await _dio.delete( '${ApiConfig.baseUrl}/kitchen/recipe_share.php', - queryParameters: {'act': 'delete', 'id': recipeId}, + queryParameters: {'id': recipeId}, ); if (response.statusCode == 200) { diff --git a/lib/src/services/data/cache_service.dart b/lib/src/services/data/storage/cache_service.dart similarity index 100% rename from lib/src/services/data/cache_service.dart rename to lib/src/services/data/storage/cache_service.dart diff --git a/lib/src/services/data/hive_service.dart b/lib/src/services/data/storage/hive_service.dart similarity index 91% rename from lib/src/services/data/hive_service.dart rename to lib/src/services/data/storage/hive_service.dart index da696e1..d00a4a3 100644 --- a/lib/src/services/data/hive_service.dart +++ b/lib/src/services/data/storage/hive_service.dart @@ -23,6 +23,7 @@ class HiveService { static const String _cookingNoteBox = 'cookingNoteBox'; static const String _favoriteBox = 'favoriteBox'; static const String _searchHistoryBox = 'searchHistoryBox'; + static const String _ratingRecordBox = 'ratingRecordBox'; static const int _currentSchemaVersion = 1; @@ -35,6 +36,7 @@ class HiveService { Box? _cookingNotes; Box? _favorites; Box? _searchHistory; + Box? _ratingRecords; Box? _versionBoxInstance; // 农场游戏 Box @@ -116,6 +118,7 @@ class HiveService { _cookingNotes = await _openBoxSafe(_cookingNoteBox); _favorites = await _openBoxSafe(_favoriteBox); _searchHistory = await _openBoxSafe(_searchHistoryBox); + _ratingRecords = await _openBoxSafe(_ratingRecordBox); // 农场游戏 Boxes _farmPlayer = await _openBoxSafe('farmPlayerBox'); @@ -177,7 +180,9 @@ class HiveService { } await _versionBoxInstance?.put(_versionKey, _currentSchemaVersion); - LoggerService().info('Hive migration completed to v$_currentSchemaVersion'); + LoggerService().info( + 'Hive migration completed to v$_currentSchemaVersion', + ); } // 初始化农场游戏数据(无论版本,仅在首次运行时) @@ -515,6 +520,42 @@ class HiveService { await _favorites!.delete(id.toString()); } + // === Rating Records CRUD === + + Future addRatingRecord(int recipeId, Map data) async { + if (!_initialized || _ratingRecords == null) return; + await _ratingRecords!.put(recipeId.toString(), data); + } + + Map? getRatingRecord(int recipeId) { + if (!_initialized || _ratingRecords == null) return null; + final raw = _ratingRecords!.get(recipeId.toString()); + if (raw == null) return null; + return Map.from(raw); + } + + bool isRated(int recipeId) { + if (!_initialized || _ratingRecords == null) return false; + return _ratingRecords!.containsKey(recipeId.toString()); + } + + List> getAllRatingRecords() { + if (!_initialized || _ratingRecords == null) return []; + return _ratingRecords!.values + .map((m) => Map.from(m)) + .toList(); + } + + Future removeRatingRecord(int recipeId) async { + if (!_initialized || _ratingRecords == null) return; + await _ratingRecords!.delete(recipeId.toString()); + } + + Future clearAllRatingRecords() async { + if (!_initialized || _ratingRecords == null) return; + await _ratingRecords!.clear(); + } + // === Search History === List getSearchHistory() { @@ -546,7 +587,9 @@ class HiveService { } // box 未打开,返回 null(需要在 init 中预初始化) - LoggerService().warning('Box $boxName is not opened. Please initialize it in init() method.'); + LoggerService().warning( + 'Box $boxName is not opened. Please initialize it in init() method.', + ); return null; } @@ -575,7 +618,9 @@ class HiveService { try { final box = _getOrOpenBox(boxName); if (box == null) { - LoggerService().warning('Box $boxName is not available for put operation'); + LoggerService().warning( + 'Box $boxName is not available for put operation', + ); return; } box.put(key, value); @@ -593,7 +638,9 @@ class HiveService { try { final box = _getOrOpenBox(boxName); if (box == null) { - LoggerService().warning('Box $boxName is not available for delete operation'); + LoggerService().warning( + 'Box $boxName is not available for delete operation', + ); return; } box.delete(key); diff --git a/lib/src/services/data/ingredient_cache_service.dart b/lib/src/services/data/storage/ingredient_cache_service.dart similarity index 100% rename from lib/src/services/data/ingredient_cache_service.dart rename to lib/src/services/data/storage/ingredient_cache_service.dart diff --git a/lib/src/services/data/local_data_service.dart b/lib/src/services/data/storage/local_data_service.dart similarity index 100% rename from lib/src/services/data/local_data_service.dart rename to lib/src/services/data/storage/local_data_service.dart diff --git a/lib/src/services/data/offline_service.dart b/lib/src/services/data/storage/offline_service.dart similarity index 100% rename from lib/src/services/data/offline_service.dart rename to lib/src/services/data/storage/offline_service.dart diff --git a/lib/src/services/data/storage_service.dart b/lib/src/services/data/storage/storage_service.dart similarity index 100% rename from lib/src/services/data/storage_service.dart rename to lib/src/services/data/storage/storage_service.dart diff --git a/lib/src/utils/platform_utils.dart b/lib/src/utils/platform_utils.dart index 159cbbf..3dbd337 100644 --- a/lib/src/utils/platform_utils.dart +++ b/lib/src/utils/platform_utils.dart @@ -1,8 +1,9 @@ // 2026-04-09 | PlatformUtils | 平台工具类 | 判断运行平台,兼容Web // 2026-04-09 | 修复Web平台Platform API崩溃问题 // 2026-04-18 | 修复鸿蒙端检测:使用动态检测避免非鸿蒙平台崩溃 +// 2026-04-19 | 修复鸿蒙端operatingSystemName返回Unknown,增加defaultTargetPlatform兜底 import 'dart:io' if (dart.library.html) 'platform_web_stub.dart'; -import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/foundation.dart' show kIsWeb, defaultTargetPlatform, TargetPlatform; class PlatformUtils { static final PlatformUtils _instance = PlatformUtils._internal(); @@ -45,14 +46,31 @@ class PlatformUtils { String get operatingSystemName { if (kIsWeb) return 'Web'; - if (_checkIsHarmonyOS()) return 'HarmonyOS'; - if (Platform.isIOS) return 'iOS'; - if (Platform.isAndroid) return 'Android'; - if (Platform.isWindows) return 'Windows'; - if (Platform.isMacOS) return 'macOS'; - if (Platform.isLinux) return 'Linux'; - if (Platform.isFuchsia) return 'Fuchsia'; - return 'Unknown'; + try { + if (_checkIsHarmonyOS()) return 'HarmonyOS'; + if (Platform.isIOS) return 'iOS'; + if (Platform.isAndroid) return 'Android'; + if (Platform.isWindows) return 'Windows'; + if (Platform.isMacOS) return 'macOS'; + if (Platform.isLinux) return 'Linux'; + if (Platform.isFuchsia) return 'Fuchsia'; + } catch (_) {} + switch (defaultTargetPlatform) { + case TargetPlatform.ohos: + return 'HarmonyOS'; + case TargetPlatform.iOS: + return 'iOS'; + case TargetPlatform.android: + return 'Android'; + case TargetPlatform.macOS: + return 'macOS'; + case TargetPlatform.windows: + return 'Windows'; + case TargetPlatform.linux: + return 'Linux'; + case TargetPlatform.fuchsia: + return 'Fuchsia'; + } } String get operatingSystemVersion => @@ -77,7 +95,8 @@ class PlatformUtils { return 'Platform: $operatingSystemName, Version: $operatingSystemVersion, Dart: $dartVersion'; } - bool get isMobile => isIOS || isAndroid || isHarmonyOS; + bool get isMobile => + isIOS || isAndroid || isHarmonyOS || defaultTargetPlatform == TargetPlatform.ohos; bool get isDesktop => isWindows || isMacOS || isLinux; diff --git a/lib/src/widgets/carousel/feature_carousel_card.dart b/lib/src/widgets/common/feature_carousel_card.dart similarity index 100% rename from lib/src/widgets/carousel/feature_carousel_card.dart rename to lib/src/widgets/common/feature_carousel_card.dart diff --git a/lib/src/widgets/image_viewer/image_viewer_page.dart b/lib/src/widgets/common/image_viewer_page.dart similarity index 100% rename from lib/src/widgets/image_viewer/image_viewer_page.dart rename to lib/src/widgets/common/image_viewer_page.dart diff --git a/lib/src/widgets/discover/mini_card_discover_card.dart b/lib/src/widgets/discover/content/mini_card_discover_card.dart similarity index 100% rename from lib/src/widgets/discover/mini_card_discover_card.dart rename to lib/src/widgets/discover/content/mini_card_discover_card.dart diff --git a/lib/src/widgets/discover/nutrition_discover_card.dart b/lib/src/widgets/discover/content/nutrition_discover_card.dart similarity index 100% rename from lib/src/widgets/discover/nutrition_discover_card.dart rename to lib/src/widgets/discover/content/nutrition_discover_card.dart diff --git a/lib/src/widgets/discover/recipe_discover_card.dart b/lib/src/widgets/discover/content/recipe_discover_card.dart similarity index 100% rename from lib/src/widgets/discover/recipe_discover_card.dart rename to lib/src/widgets/discover/content/recipe_discover_card.dart diff --git a/lib/src/widgets/discover/tag_discover_card.dart b/lib/src/widgets/discover/content/tag_discover_card.dart similarity index 100% rename from lib/src/widgets/discover/tag_discover_card.dart rename to lib/src/widgets/discover/content/tag_discover_card.dart diff --git a/lib/src/widgets/discover/tool_card_discover_card.dart b/lib/src/widgets/discover/content/tool_card_discover_card.dart similarity index 100% rename from lib/src/widgets/discover/tool_card_discover_card.dart rename to lib/src/widgets/discover/content/tool_card_discover_card.dart diff --git a/lib/src/widgets/recipe/recipe_image.dart b/lib/src/widgets/recipe/recipe_image.dart index 5498bea..be4342b 100644 --- a/lib/src/widgets/recipe/recipe_image.dart +++ b/lib/src/widgets/recipe/recipe_image.dart @@ -11,6 +11,7 @@ import 'package:flutter/cupertino.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; enum RecipeImageMode { thumbnail, full } @@ -280,6 +281,8 @@ class _RecipeImageState extends State { class RecipeImageCache { static Future clearCache() async { - await CachedNetworkImage.evictFromCache(''); + await DefaultCacheManager().emptyCache(); + PaintingBinding.instance.imageCache.clear(); + PaintingBinding.instance.imageCache.clearLiveImages(); } } diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 65fcdb7..e2601b6 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -7,7 +7,7 @@ project(runner LANGUAGES CXX) set(BINARY_NAME "mom_kitchen") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.example.mom_kitchen") +set(APPLICATION_ID "cute.major.kitchen") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/ohos/AppScope/app.json5 b/ohos/AppScope/app.json5 index f93cd1f..4595231 100644 --- a/ohos/AppScope/app.json5 +++ b/ohos/AppScope/app.json5 @@ -1,6 +1,6 @@ { "app": { - "bundleName": "com.example.ohos", + "bundleName": "cute.major.kitchen", "vendor": "example", "versionCode": 1000000, "versionName": "1.0.0", diff --git a/ohos/build-profile.json5 b/ohos/build-profile.json5 index ab5afce..63deb27 100644 --- a/ohos/build-profile.json5 +++ b/ohos/build-profile.json5 @@ -7,11 +7,24 @@ "material": { "certpath": "C:\\Users\\无书\\.ohos\\config\\default_ohos_h8eBDwGJTRHPEcoIPNZ4JJ58-IFgoGWW5H7lci4Iucs=.cer", "keyAlias": "debugKey", - "keyPassword": "0000001B20C6DB437FE37A1C0E37ED7E0261B37FAE7B7D0B419650E8CE5F10C5B2A7EB1F3302E68A182ADF", + "keyPassword": "0000001B7CAB09D50746C6C30A6F40391D1E7312D175D71ABA92CE315B118861B83894F09A6A31EED9F47C", "profile": "C:\\Users\\无书\\.ohos\\config\\default_ohos_h8eBDwGJTRHPEcoIPNZ4JJ58-IFgoGWW5H7lci4Iucs=.p7b", "signAlg": "SHA256withECDSA", "storeFile": "C:\\Users\\无书\\.ohos\\config\\default_ohos_h8eBDwGJTRHPEcoIPNZ4JJ58-IFgoGWW5H7lci4Iucs=.p12", - "storePassword": "0000001B49A997A982498D203DFAE13661F6DC1AC5BF10F97899F0332041B738EE3D08D22ABB88E3712C78" + "storePassword": "0000001B08D1DA1FD0A47C3BD8AFB95D785667DD24E1F18CDFAB4836835C917F1666F8AC6FC4DC946B53E8" + } + }, + { + "name": "release", + "type": "HarmonyOS", + "material": { + "certpath": "D:/zhshu/kitchen/520kiss123-a.cer", + "keyAlias": "520kiss123", + "keyPassword": "0000001A4DAEF40C4A47DA7688BA7593FBE673FF818E0278CEA3ABCFFDE9C8B4CBBFA6048330176D60FC", + "profile": "D:/zhshu/kitchen/520kiss123Release (1).p7b", + "signAlg": "SHA256withECDSA", + "storeFile": "D:/zhshu/kitchen/520kiss123.p12", + "storePassword": "0000001AC2DB92FF9C608EA0046BC9DBFE33AEAD70C60C86913D75E7A7AC6F01C86CBB037B5DE83B4F79" } } ], @@ -22,6 +35,13 @@ "compatibleSdkVersion": "5.1.0(18)", "runtimeOS": "HarmonyOS", "targetSdkVersion": "6.0.2(22)" + }, + { + "name": "release", + "signingConfig": "release", + "compatibleSdkVersion": "5.1.0(18)", + "runtimeOS": "HarmonyOS", + "targetSdkVersion": "6.0.2(22)" } ], "buildModeSet": [ @@ -44,7 +64,8 @@ { "name": "default", "applyToProducts": [ - "default" + "default", + "release" ] } ] diff --git a/packages/fluttertoast_ohos/ohos/build/default/intermediates/merge_profile/default/module.json b/packages/fluttertoast_ohos/ohos/build/default/intermediates/merge_profile/default/module.json index 879e9c3..78266a5 100644 --- a/packages/fluttertoast_ohos/ohos/build/default/intermediates/merge_profile/default/module.json +++ b/packages/fluttertoast_ohos/ohos/build/default/intermediates/merge_profile/default/module.json @@ -1,9 +1,9 @@ { "app": { - "bundleName": "com.example.ohos", + "bundleName": "cute.major.kitchen", "debug": true, - "versionCode": 95, - "versionName": "0.96.0", + "versionCode": 100, + "versionName": "0.99.1", "minAPIVersion": 50100018, "targetAPIVersion": 60002022, "apiReleaseType": "Release", diff --git a/packages/fluttertoast_ohos/ohos/build/release/intermediates/merge_profile/default/module.json b/packages/fluttertoast_ohos/ohos/build/release/intermediates/merge_profile/default/module.json new file mode 100644 index 0000000..da5d364 --- /dev/null +++ b/packages/fluttertoast_ohos/ohos/build/release/intermediates/merge_profile/default/module.json @@ -0,0 +1,28 @@ +{ + "app": { + "bundleName": "cute.major.kitchen", + "debug": false, + "versionCode": 95, + "versionName": "0.96.0", + "minAPIVersion": 50100018, + "targetAPIVersion": 60002022, + "apiReleaseType": "Release", + "targetMinorAPIVersion": 0, + "targetPatchAPIVersion": 0, + "compileSdkVersion": "6.0.2.130", + "compileSdkType": "HarmonyOS", + "appEnvironments": [], + "bundleType": "app", + "buildMode": "release" + }, + "module": { + "name": "fluttertoast_ohos", + "type": "har", + "deviceTypes": [ + "default", + "tablet" + ], + "packageName": "fluttertoast_ohos", + "installationFree": false + } +} diff --git a/packages/本地已适配鸿蒙的库.md b/packages/本地已适配鸿蒙的库.md index 8b131c2..2da0cbe 100644 --- a/packages/本地已适配鸿蒙的库.md +++ b/packages/本地已适配鸿蒙的库.md @@ -154,6 +154,8 @@ flutter pub get && flutter analyze --no-pub | 4 | cached_network_image | 3.4.1 | 3.4.1-ohos.1 | 2026-04-12 | ✅ analyze 通过 | | 5 | flutter_markdown_plus | 1.0.7 | 1.0.7-ohos.1 | 2026-04-14 | ✅ analyze 通过 | | 6 | flutter_card_swiper | 7.2.0 | 7.2.0-ohos.1 | 2026-04-14 | ✅ analyze 通过 | +| 7 | qr | 3.0.2 | 3.0.2-ohos.1 | 2026-04-19 | ✅ pub get 通过 | +| 8 | mailer | 7.1.0 | 7.1.0-ohos.1 | 2026-04-19 | ✅ pub get 通过 | ### 5.2 各包克隆命令速查 @@ -178,6 +180,12 @@ git clone --depth 1 --branch v1.0.7 https://github.com/foresightmobile/flutter_m # 6. flutter_card_swiper git clone --depth 1 --branch v7.2.0 https://github.com/ricardodalarme/flutter_card_swiper.git flutter_card_swiper + +# 7. qr +git clone --depth 1 --branch v3.0.2 https://github.com/kevmoo/qr.dart.git qr + +# 8. mailer +git clone --depth 1 --branch v7.1.0 https://github.com/dart-mailer/mailer.git mailer ``` ### 5.3 各包使用示例 @@ -218,6 +226,22 @@ import 'package:flutter_card_swiper/flutter_card_swiper.dart'; CardSwiper(itemCount: cards.length, itemBuilder: (ctx, index) => CardWidget(cards[index]), onSwipe: (prev, curr, direction) {}) ``` +**qr** +```dart +import 'package:qr/qr.dart'; +final qrCode = QrCode(4, QrErrorCorrectLevel.L)..addData('Hello, world!'); +final qrImage = QrImage(qrCode); +``` + +**mailer** +```dart +import 'package:mailer/mailer.dart'; +import 'package:mailer/smtp_server.dart'; +final smtpServer = gmail('user@gmail.com', 'password'); +final message = Message()..from = Address('user@gmail.com')..recipients.add('target@example.com')..subject = 'Test'..text = 'Hello'; +final sendReport = await send(message, smtpServer); +``` + --- ## 六、项目依赖兼容性总览 @@ -230,6 +254,8 @@ CardSwiper(itemCount: cards.length, itemBuilder: (ctx, index) => CardWidget(card | cached_network_image | 本地 path | ✅ | ✅ | 条件导入,IO 分支 | | flutter_markdown_plus | 本地 path | ✅ | ✅ | 纯 Dart,全平台支持 | | flutter_card_swiper | 本地 path | ✅ | ✅ | 纯 Dart | +| qr | 本地 path | ✅ | ✅ | 纯 Dart,QR码生成 | +| mailer | 本地 path | ❌ | ✅ | 纯 Dart,SMTP客户端,Web不支持 | | hive_ce | pub.dev | ✅ | ✅ | 纯 Dart | | get / dio / logger / intl | pub.dev | ✅ | ✅ | 纯 Dart | | shared_preferences | pub.dev | ✅ | ✅ | localStorage | diff --git a/pubspec.lock b/pubspec.lock index 4230c36..11d40db 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -621,11 +621,10 @@ packages: mailer: dependency: "direct main" description: - name: mailer - sha256: "7b8691b080809ea1b2fa2f1b0d49c7c089fb328bd23e68aa5818b9cf5f4b420d" - url: "https://pub.flutter-io.cn" - source: hosted - version: "7.1.0" + path: "packages/mailer" + relative: true + source: path + version: "7.1.0-ohos.1" markdown: dependency: transitive description: @@ -915,11 +914,10 @@ packages: qr: dependency: "direct main" description: - name: qr - sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.0.2" + path: "packages/qr" + relative: true + source: path + version: "3.0.2-ohos.1" receive_sharing_intent: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 2988e8d..07efc15 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.96.0+95 +version: 0.99.1+100 environment: sdk: ^3.9.2 @@ -148,9 +148,11 @@ dependencies: hive_ce: ^2.11.0 - qr: ^3.0.2 + qr: + path: packages/qr - mailer: ^7.1.0 + mailer: + path: packages/mailer flutter_dotenv: ^5.2.1 @@ -181,6 +183,8 @@ dependency_overrides: path: packages/device_info_plus/device_info_plus fluttertoast: path: packages/fluttertoast + mailer: + path: packages/mailer # For information on the generic Dart part of this file, see the