diff --git a/CHANGELOG.md b/CHANGELOG.md index c25d3a5..8bbc045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,424 +3,155 @@ All notable changes to this project will be documented in this file. -## [0.98.2] - 2026-04-18 +## [0.98.9] - 2026-04-18 -### 🐛 修复 — 小妈菜园性能优化 + GetX报错修复 +### ✨ 新增 — 菜谱分享本地存储 + 3天自动过期清理 + +#### 菜谱分享数据推送(Flutter → PHP) +- 📤 **RecipeShareService**:新增菜谱分享数据推送服务,将菜谱数据推送到 `recipe_share.php` 本地存储 +- 🔄 **先推送后生成**:二维码海报生成前先推送数据到服务器,推送失败仍显示海报(降级策略) +- 📱 **完整数据推送**:推送菜谱标题/简介/封面/分类/食材/营养/评分等完整信息 +- 🎯 **CRUD接口**:支持创建/更新/删除菜谱分享数据 + +#### recipe_share.php 重构 +- 💾 **本地JSON存储**:从调用外部API改为本地JSON文件存储(`cache/kitchen/recipe_*.json`) +- ⏰ **3天自动过期**:数据写入时自动设置3天过期时间,读取时自动检查并清理过期文件 +- 🧹 **清理接口**:新增 `?act=cleanup` 接口,手动清理所有过期数据 +- 📋 **数据目录共享**:与 `kitchen.php` 共享 `cache/kitchen/` 目录 + +#### 二维码海报组件改进 +- 🎨 **加载提示**:推送数据时显示 CupertinoActivityIndicator 加载动画 +- 📦 **RecipeModel传递**:`showQrPosterSheet` 新增 `recipe` 参数,支持完整菜谱数据传递 +- 🔗 **recipe_action_bar**:新增 `recipe` 字段,详情页传递完整菜谱对象 + +#### 修改文件 +- `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天过期清理功能 + +## [0.98.8] - 2026-04-18 + +### ✨ 新增 — 二维码分享URL生成 + PHP分享页面 + +#### 二维码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 + +#### PHP分享页面(recipe_share.php) +- 🍳 **美观分享页**:iOS风格设计,深色模式自适应,毛玻璃效果,流畅动画 +- 📊 **访问统计**:自动记录每次扫码访问,按菜谱统计浏览量 +- 📝 **访问日志**:记录IP、UA、来源等,保留最近200条 +- 🏷️ **OG标签**:支持微信/社交媒体分享预览(标题/描述/封面图) +- 🔌 **API代理**:通过调用 `api.php` 获取数据,不直接查询数据库 +- 📈 **管理接口**:`?act=stats` 查看统计、`?act=log` 查看日志、`?act=api` 返回JSON + +#### 修改文件 +- `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分享页面 + +## [0.98.7] - 2026-04-18 + +### ✨ 新增 — 口味偏好持久化 + 菜品详情页偏好标注 + +#### 口味偏好持久化 +- 💾 **TastePreferenceService**:新增口味偏好持久化服务,所有偏好值写入 SharedPreferences +- 🥗 **饮食类型/辣度/口味/烹饪水平/人数/健康目标**:6项偏好值全部持久化,应用重启后自动恢复 +- 🔄 **PreferencePage 接入**:偏好设置页改用 `TastePreferenceService` 读写,移除本地 State 临时变量 +- 🧹 **枚举统一**:DietType/SpiceLevel/CookingLevel/ServingSize/HealthGoal 统一定义在 `TastePreferenceService` 中 + +#### 菜品详情页偏好标注 +- 💚 **我的偏好卡片**:在菜品详情页过敏原警告下方新增「我的偏好」标注卡片 +- 🌶️ **辣度匹配**:对比用户辣度偏好与菜品口味标签,标注「很匹配/一般/不太匹配」 +- 👅 **口味匹配**:对比用户甜/咸/酸偏好与菜品口味,计算匹配度 +- 👨‍🍳 **难度匹配**:对比用户烹饪水平与菜品难度,标注匹配度 +- 🥗 **饮食类型匹配**:检测菜品食材是否包含用户饮食禁忌(素食/纯素/低碳水/生酮/清真等) +- ⚠️ **过敏原标注**:只标注用户选择的过敏原,显示「安全」或「含过敏原」 + +#### 修改文件 +- `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 + +## [0.98.6] - 2026-04-18 + +### ✨ 新增与改进 + +#### 软件信息页面 +- 🖼️ **头部卡片图标**:将 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 确认,支持复制链接 + +#### 修改文件 +- `lib/src/pages/profile/app_info_page.dart` — 新增软件信息页面 +- `lib/src/pages/profile/learn_us_page.dart` — 新增了解我们页面 +- `lib/src/pages/profile/about_page.dart` — 软件信息/了解我们入口改为跳转新页面 + + +## [0.98.4] - 2026-04-18 + +### 🐛 修复 — 小妈菜园交互优化 + 商店布局修复 #### 变更 -- ⏱️ **生长计时器间隔优化**:从10秒调整为30秒,减少不必要的性能开销,配置项移入 `FarmConfig` -- 🔄 **应用生命周期管理**:添加 `WidgetsBindingObserver`,应用进入后台时暂停定时器,恢复前台时立即更新生长状态并重启定时器 -- 🐛 **修复GetX improper use报错**:重构 `_buildGardenGrid` 中 `Obx` 与 `LayoutBuilder` 的嵌套顺序,确保可观察变量在 `Obx` 直接作用域内被访问 -- 🌾 **空数据状态处理**:菜园数据为空时显示加载提示,避免 Obx 无响应式变量注册 +- 🔇 **消息限流机制**:添加 Toast 限流(5秒内最多2次,10秒内最多3次),重复消息自动过滤,避免气泡弹出过于频繁 +- 🔄 **消息系统统一**:所有 `Get.snackbar` 替换为 `ToastService`,统一使用项目消息服务 +- 📐 **商店布局重构**:种子商店卡片从单行布局改为上下分行(名称+购买按钮同行,信息标签换行),使用 `Wrap` 防止溢出 +- 📤 **分享功能确认**:`FarmShareUtil` 已支持图片+文本分享(`Share.shareXFiles` + `text`),无需修改 #### 修改文件 -- `lib/src/controllers/farm/farm_game_controller.dart` — 计时器间隔配置化、添加生命周期管理 -- `lib/src/pages/tools/farm/farm_game_page.dart` — 修复 Obx 嵌套顺序、添加空数据状态 -- `lib/src/config/farm_config.dart` — 新增 `growthCheckIntervalSeconds` 配置项 +- `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` — 重构卡片布局,信息标签和购买按钮分行 -## [0.98.1] - 2026-04-18 -### 🐛 修复 — 收藏页面排序功能恢复 + 编辑模式侧栏悬浮 - -#### 变更 -- 🔀 **恢复排序功能**:将排序按钮集成到类型标签栏右侧,显示当前排序方式标签(最新/最早/A-Z/Z-A) -- 🎯 **排序弹窗优化**:当前选中排序方式显示 ✓ 标识,每个选项增加 Emoji 图标 -- 📌 **编辑模式改为侧栏悬浮**:底部操作栏改为右侧悬浮按钮组(取消/删除/全选),不再被底部 tab 栏遮挡 -- 🧹 **清理冗余代码**:移除未使用的 `_buildToolbar` 和 `_buildToolbarButton` 方法 - -#### 修改文件 -- `lib/src/pages/profile/social/favorites_page.dart` — 恢复排序入口、编辑模式改为悬浮侧栏 - - -## [0.98.0] - 2026-04-18 - -### 🌾 新增 — 小妈菜园游戏系统 - -#### 核心游戏功能 -- 🌱 **种植系统**:12 块土地,支持播种、浇水、收获、清理枯萎作物 -- ⏰ **实时生长计时**:4 阶段生长过程(种子→幼苗→生长中→成熟),10秒自动刷新 -- 💧 **浇水机制**:浇水提升生长速度,2小时未浇水作物枯萎 -- 🎉 **收获奖励**:获得金币和经验值,果实自动存入背包 -- 🔓 **土地扩建**:消耗 200 金币解锁新土地 - -#### 玩家系统 -- ⭐ **等级经验系统**:升级奖励金币+钻石,自动解锁新作物 -- 🎒 **背包系统**:种子/果实分类管理,购买/收获自动更新 -- 🏪 **种子商店**:12种作物,不同生长时间和价格,按等级解锁 -- 🏆 **成就系统**:6个初始成就(初次收获、丰收达人、等级达成等) - -#### 作物配置 -- 🥕 **12种作物**:萝卜、土豆、卷心菜、西红柿、玉米、辣椒、茄子、草莓、南瓜、西瓜、葡萄 -- 📊 **生长阶段**:每个作物 4 个生长阶段,不同 Emoji 显示 -- 💰 **经济系统**:金币购买种子,收获获得更高回报 -- 🔒 **等级解锁**:从 Lv.1 到 Lv.10 逐步解锁所有作物 - -#### 技术实现 -- 💾 **Hive 本地存储**:3个 Box(玩家/土地/背包),手动编写适配器 -- 🎨 **iOS 26 Liquid Glass 风格**:Cupertino 组件 + DesignTokens 统一主题 -- 🌗 **深色/浅色模式**:完美适配双主题 -- 🐛 **调试功能**:加金币、加速作物、解锁全部、重置数据 -- 📱 **响应式布局**:3-4列自适应网格,支持平板和桌面 - -#### 修改文件 -- `lib/src/models/farm/farm_player.dart` + `.g.dart` — 玩家数据模型 -- `lib/src/models/farm/farm_land.dart` + `.g.dart` — 土地数据模型 -- `lib/src/models/farm/inventory_item.dart` + `.g.dart` — 背包物品模型 -- `lib/src/models/farm/crop_config.dart` — 作物配置 -- `lib/src/models/farm/achievement_config.dart` — 成就配置 -- `lib/src/models/farm/crop_registry.dart` — 12种作物注册表 -- `lib/src/models/farm/achievement_registry.dart` — 6个成就注册表 -- `lib/src/config/farm_config.dart` — 游戏全局配置 -- `lib/src/controllers/farm/farm_game_controller.dart` — 核心游戏控制器 -- `lib/src/controllers/farm/farm_shop_controller.dart` — 商店控制器 -- `lib/src/controllers/farm/farm_inventory_controller.dart` — 背包控制器 -- `lib/src/controllers/farm/farm_achievement_controller.dart` — 成就控制器 -- `lib/src/pages/tools/farm/farm_game_page.dart` — 主游戏页面 -- `lib/src/pages/tools/farm/farm_shop_page.dart` — 种子商店页面 -- `lib/src/pages/tools/farm/farm_inventory_page.dart` — 背包页面 -- `lib/src/pages/tools/farm/farm_achievement_page.dart` — 成就中心页面 -- `lib/src/services/data/hive_service.dart` — 扩展农场数据 Box -- `lib/src/config/app_routes.dart` — 新增 4 个农场路由 -- `lib/src/models/tool_item_model.dart` — 注册工具中心入口 -- `lib/src/app_binding.dart` — 注册游戏控制器 - - -## [0.97.38] - 2026-04-18 - -### 🐛 修复 — 7个UI/功能Bug - -#### Bug修复 -- 🐛 **收藏页面**:移除"全部菜品"下方多余的"全部菜谱"栏 -- 🐛 **收藏页面**:修复编辑按钮报错 `Incorrect use of ParentDataWidget`(Positioned放在Column中) -- 🐛 **收藏页面**:修复编辑模式底部操作栏被底部Tab遮住的问题 -- 🐛 **缓存管理**:修复点击已缓存菜谱跳转显示加载失败(路由参数格式错误) -- 🐛 **数据导出**:修复弹出系统分享后未操作就显示导出成功(Toast时机修正) -- 🐛 **笔记页面**:添加右上角缺失的添加笔记按钮 -- 🐛 **缓存管理**:优化已缓存菜品和食材列表,改为可折叠展示 - -### ✨ 新增 — 数据导入功能 - -#### 数据导入 -- 📥 **DataExportService 导入服务**:支持 JSON 格式导入,与导出格式一致 - - `previewImport()` 方法:预览导入数据,显示各数据源条数 - - `importFromJson()` 方法:执行导入,支持选择数据源 - - `ImportPreview` 类:导入预览数据模型 -- 📥 **DataExportPage 导入UI**:新增"数据导入"区域 - - 选择 JSON 文件导入入口 - - 从其他应用分享导入入口 - - 导入预览(显示文件名、各数据源条数) - - 确认导入对话框 -- 🔌 **receive_sharing_intent 集成**:支持从其他应用分享 JSON 文件到本应用 - - 监听分享流(getMediaStream)和初始分享(getInitialMedia) - - 自动识别 JSON 文件并预览 -- 🔌 **各 Controller importFromJson 方法**: - - `FavoritesController`: addFavoriteFromJson - - `ShoppingListController`: importFromJson - - `MealRecordController`: importFromJson - - `CookingNoteController`: importFromJson - - `WeeklyMenuController`: importFromJson - - `BrowseHistoryController`: importFromJson - -#### 平台配置 -- 🤖 **Android**:AndroidManifest.xml 添加 SEND/SEND_MULTIPLE/VIEW intent-filter(application/json) -- 🍎 **iOS**:Info.plist 添加 CFBundleDocumentTypes + UTImportedTypeDeclarations(public.json) -- 📱 **鸿蒙**:module.json5 添加 ohos.want.action.sendData skill + general.entity/general.object utd + READ_MEDIA 权限 - -#### 修改文件 -- `lib/src/pages/profile/social/favorites_page.dart` — 修复布局Bug -- `lib/src/pages/profile/data/cache_manage_page.dart` — 可折叠列表优化 -- `lib/src/pages/profile/data_export_page.dart` — 新增导入UI + receive_sharing_intent集成 -- `lib/src/pages/tools/cooking/cooking_note_page.dart` — 添加笔记按钮 -- `lib/src/config/app_routes.dart` — 修复路由参数 -- `lib/src/services/data/data_export_service.dart` — 新增导入功能 -- `lib/src/controllers/data/favorites_controller.dart` — 新增 addFavoriteFromJson -- `lib/src/controllers/data/shopping_list_controller.dart` — 新增 importFromJson -- `lib/src/controllers/data/meal_record_controller.dart` — 新增 importFromJson -- `lib/src/controllers/data/cooking_note_controller.dart` — 新增 importFromJson -- `lib/src/controllers/data/weekly_menu_controller.dart` — 新增 importFromJson -- `lib/src/controllers/data/browse_history_controller.dart` — 新增 importFromJson -- `lib/src/models/data/weekly_menu_model.dart` — DayMenu 添加 copyWith -- `android/app/src/main/AndroidManifest.xml` — 添加分享intent-filter -- `ios/Runner/Info.plist` — 添加JSON文件类型声明 -- `ohos/entry/src/main/module.json5` — 添加分享skill和权限 -- `ohos/entry/src/main/resources/base/element/string.json` — 添加权限说明 -- `ohos/entry/src/main/resources/zh_CN/element/string.json` — 添加中文权限说明 -- `ohos/entry/src/main/resources/en_US/element/string.json` — 添加英文权限说明 - - -## [0.97.37] - 2026-04-18 - -### ✨ 新增 — 离线模式增强 + 数据导出功能 - -#### 离线模式增强 -- 📡 **OfflineService 离线服务核心**:统一管理离线状态、操作守卫、功能可用性列表 - - `guard()` 方法:离线时拦截操作,支持排队等待网络恢复后自动执行 - - 可用/不可用功能列表:离线时明确告知用户哪些功能可用 - - 离线持续时间追踪:显示已离线时长 - - 排队操作计数:显示待执行操作数量 -- 🔔 **增强离线指示器 OfflineIndicator**:显示离线状态+持续时间+排队数,点击查看详情 -- 🔄 **ConnectivityService 生命周期感知**:监听 App 生命周期,`resumed` 时重新检查网络状态 -- 🛡️ **DNS 预检重置机制**:后台恢复时自动重置 DNS 预检状态,避免假离线 - -#### 数据导出功能 -- 📦 **DataExportService 统一导出服务**:支持 6 种数据源、3 种格式(JSON/CSV/Markdown) - - 数据源:收藏、购物清单、饮食记录、烹饪笔记、每周菜单、浏览记录 - - 格式:JSON(数据备份)、CSV(Excel 编辑)、Markdown(阅读分享) - - 支持单源导出和一键全量导出 - - 导出后支持系统分享 -- 📄 **DataExportPage 数据导出页面**:iOS 风格 UI,选择格式+数据源+导出/分享 -- 🔌 **各 Controller 导出方法**: - - `FavoritesController`: exportToJson/exportToCsv/exportToMarkdown - - `ShoppingListController`: exportToJson/exportToCsv/exportToMarkdown - - `MealRecordController`: exportToJson/exportToCsv/exportToMarkdown - - `CookingNoteController`: exportToJson/exportToCsv/exportToMarkdown - - `WeeklyMenuController`: exportToJson/exportToCsv/exportToMarkdown - - `BrowseHistoryController`: exportToJson/exportToCsv - -#### 🐛 修复 — 后台恢复假离线 -- **问题**:返回桌面后,后台会断网,再打开一直显示无网络,过一会被系统清理后台 -- **根因**:应用从后台恢复时,DNS 预检结果过期(`_dnsChecked=true` 但 `_dnsReachable=false`),5 分钟内不重新检查,导致所有请求走缓存或失败 -- **修复**: - - `ConnectivityService` 添加 `WidgetsBindingObserver`,`resumed` 时主动检查网络 - - 网络恢复时触发 DNS 预检重置(`ApiService.resetDnsCheck()`) - - 新增异步恢复回调机制 `addOnNetworkRestoredAsync()` - -#### 修改文件 -- `lib/src/services/data/offline_service.dart` — 新建:离线服务核心 -- `lib/src/services/data/data_export_service.dart` — 新建:数据导出服务 -- `lib/src/widgets/states/offline_indicator.dart` — 新建:增强版离线指示器 -- `lib/src/pages/profile/data_export_page.dart` — 新建:数据导出页面 -- `lib/src/services/connectivity_service.dart` — 生命周期感知+DNS重检+异步恢复回调 -- `lib/src/services/api/api_service.dart` — 新增 resetDnsCheck() 方法 -- `lib/src/services/data/hive_service.dart` — 新增 getAllMealRecords() 方法 -- `lib/src/widgets/states/offline_banner.dart` — 优先使用 OfflineService 数据源 -- `lib/src/app_binding.dart` — 注册 OfflineService 和 DataExportService -- `lib/src/services/core/app_service.dart` — 注册 DNS 重检回调 -- `lib/src/config/app_routes.dart` — 新增 dataExport 路由 -- `lib/src/pages/profile/profile_settings.dart` — 新增数据导出入口 -- `lib/src/controllers/data/favorites_controller.dart` — 新增 exportToMarkdown -- `lib/src/controllers/data/shopping_list_controller.dart` — 新增 exportToJson/exportToCsv/exportToMarkdown -- `lib/src/controllers/data/meal_record_controller.dart` — 新增 exportToJson/exportToCsv/exportToMarkdown -- `lib/src/controllers/data/cooking_note_controller.dart` — 新增 exportToJson/exportToCsv/exportToMarkdown -- `lib/src/controllers/data/weekly_menu_controller.dart` — 新增 exportToJson/exportToCsv/exportToMarkdown -- `lib/src/controllers/data/browse_history_controller.dart` — 新增 exportToJson/exportToCsv - - -## [0.97.36] - 2026-04-17 - -### 🔧 修复 — Web端 Platform._operatingSystem 崩溃 - -#### 问题描述 -- 🐛 **Web端崩溃**:`Unsupported operation: Platform._operatingSystem` -- 🐛 **根因**:`crash_guard_service.dart` 直接使用 `dart:io` 的 `Platform` 类 -- 🐛 **影响范围**:错误报告生成(L137) + 错误对话框显示(L426) - -#### 修复方案 -- 🔄 **移除直接导入**:删除 `import 'dart:io'`,改用项目已有的 `PlatformUtils` -- 🛡️ **使用兼容层**:`Platform.operatingSystem` → `PlatformUtils().operatingSystemName` -- ✅ **Web端安全**:`PlatformUtils` 使用条件导入,Web端返回 `'Web'` - -#### 修改文件 -- `lib/src/services/crash_guard_service.dart` — 移除dart:io导入,使用PlatformUtils - - -## [0.97.35] - 2026-04-17 - -### 🔧 修复 — Web版API超时错误 (v2) - -#### 问题描述 -- 🐛 **超时错误**:Web端显示"加载失败 - Exception: 加载超时,请检查网络连接" -- 🐛 **根因1**:`connectTimeout: 2秒` 太短,Web端经过CORS代理链路长 -- 🐛 **根因2**:请求策略错误(先走代理再直连),代理不稳定导致延迟更高 - -#### 修复方案 -- ⏱️ **增加超时时间**:Web端 `connectTimeout` 从 2s → **10s** -- 🔄 **反转请求策略**:**优先直连** → 失败后用CORS代理备用 -- 📡 **增强错误检测**:新增 `_shouldTryProxy()` 方法,检测 connectionTimeout/connectionError/statusCode=0/5xx 等情况触发代理回退 -- 🛡️ **全方法覆盖**:GET/POST/PUT/DELETE 四种请求方法均支持新策略 - -#### 请求流程变更 -``` -旧流程 (v1): 浏览器 → CORS代理(2s超时) → API服务器 ❌ 超时 -新流程 (v2): 浏览器 → 直连API(10s超时) ✅ → 失败时 → CORS代理备用 -``` - -#### 修改文件 -- `lib/src/services/api/api_service.dart` — Web端10s超时、优先直连、_buildProxyUrl备用、_shouldTryProxy检测 - - -## [0.97.34] - 2026-04-17 - -### 🔧 修复 — Web版API连接错误 - -#### 问题描述 -- 🐛 **CORS代理失败**:Web端使用 corsproxy.io 代理服务不稳定,导致 API 请求失败 -- 错误信息:`ApiException(unknown). The connection errored. The XMLHttpRequest onerror callback was called.` - -#### 修复方案 -- 🔄 **直接请求回退机制**:CORS代理失败时自动尝试直连原始URL -- 📡 **智能错误检测**:新增 `_isCorsProxyError()` 方法识别 CORS 代理相关错误 -- 🛡️ **全方法覆盖**:GET/POST/PUT/DELETE 四种请求方法均支持回退逻辑 -- 📝 **详细日志**:添加调试日志便于追踪请求路径(代理/直连) - -#### 技术实现 -```dart -// 核心流程: -// 1. 尝试通过 CORS 代理请求 (corsproxy.io/?url=...) -// 2. 失败时检测是否为代理错误 (_isCorsProxyError) -// 3. 自动切换为直接请求原始 URL -// 4. 直连失败则抛出原始错误 -``` - -#### 修改文件 -- `lib/src/services/api/api_service.dart` — 新增 _buildDirectUrl、_isCorsProxyError 方法;修改 get/post/put/delete 和 _executeWithOfflineCheck 支持回退 - - -## [0.97.33] - 2026-04-17 - -### ✨ 新增 — 隐私政策与用户协议页面 + 首次引导页 - -#### 新增功能 -- 🔒 **隐私政策页面**:新增隐私政策与用户协议页面,支持分段切换和左右滑动浏览 -- 📋 **公开组件**:`PrivacyPolicyContent` 和 `UserAgreementContent` 为公开类,可供其他页面调用 -- 🔗 **关于页面跳转**:关于页面"软件协议"入口点击后跳转至新页面 -- 🎬 **首次引导页**:新增引导页,首次启动展示欢迎信息和协议,用户同意后方可使用 -- ✅ **协议拦截**:启动时检查协议同意状态,未同意则跳转引导页,不同意则退出应用 - -#### 修改文件 -- `lib/src/pages/profile/privacy_policy_page.dart` — 新增隐私政策与用户协议页面 -- `lib/src/pages/profile/guide_page.dart` — 新增首次引导页(欢迎+协议同意) -- `lib/src/pages/profile/about_page.dart` — 软件协议入口跳转新页面 -- `lib/src/config/app_routes.dart` — 注册 `/privacy-policy`、`/guide` 路由 -- `lib/main.dart` — 启动时检查协议同意状态 - - - - - -## [0.97.29] - 2026-04-17 - -### ✨ 新增 — 点餐助手PHP后端 + SSE实时推送 + 数据清理 - -#### 功能描述 -- 🖥️ **PHP后端API**:kitchen.php 完整CRUD接口,JSON文件存储,文件锁保证并发安全 -- 📡 **SSE实时推送**:kitchen_sse.php Server-Sent Events端点,App更新后网页端实时刷新 -- 🗑️ **数据清理**:App端支持7天/30天过期清理、本地+服务器联合清理、清空全部历史 -- ☁️ **远程同步**:OrderApiService 对接真实API,创建/更新/删除操作同步到服务器 -- 🌐 **网页端SSE**:web_order/index.html 接入SSE,实时显示连接状态,自动重连+轮询降级 - -#### API接口 -| 操作 | 方法 | URL | -|------|------|-----| -| 创建点单 | POST | kitchen.php?act=create | -| 获取点单 | GET | kitchen.php?act=get&id=xxx | -| 更新点单 | POST | kitchen.php?act=update | -| 点单列表 | GET | kitchen.php?act=list&page=1&limit=20 | -| 删除点单 | GET | kitchen.php?act=delete&id=xxx | -| 清理过期 | GET | kitchen.php?act=cleanup&days=30 | -| 统计信息 | GET | kitchen.php?act=stats | -| SSE推送 | GET | kitchen_sse.php?order_id=xxx | - -#### 新增文件 -- `docs/api/kitchen.php` — 点餐助手PHP后端API(CRUD + JSON存储 + 过期清理) -- `docs/api/kitchen_sse.php` — SSE实时推送端点(监听订单变化,推送更新) - -#### 修改文件 -- `lib/src/services/tools/order_api_service.dart` — Mock→真实API,新增 deleteOrder/cleanupExpired/getStats -- `lib/src/controllers/tools/order_assistant_controller.dart` — 新增 cleanupExpiredLocal/cleanupExpiredRemote/cleanupAllExpired -- `lib/src/pages/tools/cooking/order_assistant_page.dart` — 新增🗑️数据清理按钮和清理弹窗 -- `lib/src/models/tools/order_model.dart` — qrUrl 更新为 kitchen.php?act=get&id=xxx -- `web_order/index.html` — 接入SSE实时推送,新增连接状态指示器,轮询降级 - - -## [0.97.25] - 2026-04-16 - -### ✨ 新增 — 菜品排名(Tier List)工具 - -#### 功能描述 -- 🏆 **五级排行体系**:夯(红) → 顶级(橙金) → 人上人(黄) → NPC(米白) → 拉完了(灰白) -- 📖 **浏览记录导入**:从浏览历史中选择菜品加入排名 -- ❤️ **收藏导入**:从收藏列表中选择菜品加入排名 -- ✏️ **手动输入**:自定义菜品名称 + 选择 emoji 图标 -- 🔍 **搜索过滤**:选择面板内支持实时搜索 -- 🔄 **跨层级移动**:点击菜品可移动到其他层级 -- 🗑️ **删除/清空**:支持单条删除和一键清空 -- 💾 **本地持久化**:数据通过 SharedPreferences 本地存储 -- 🎬 **交错入场动画**:每行依次 slide + fade 进入 - -#### 新增文件 -- `lib/src/models/dish_rank_model.dart` — 菜品排名数据模型 + 层级定义常量 -- `lib/src/pages/tools/ranking/dish_ranking_controller.dart` — 排名控制器(数据管理、持久化) -- `lib/src/pages/tools/ranking/dish_ranking_page.dart` — Tier List 主页面 -- `lib/src/pages/tools/ranking/dish_pick_sheet.dart` — 底部选择面板组件 - -#### 修改文件 -- `lib/src/models/tool_item_model.dart` — 注册「菜品排名」工具项(id: dish_ranking, route: /tools/dish-ranking) -- `lib/src/config/app_routes.dart` — 注册路由 /tools/dish-ranking - - -## [0.97.22] - 2026-04-16 - -### 🐛 修复 — 发现页工具中心交互优化 - -#### 变更 -- 🔽 **从底部滑入**:工具中心改为从底部滑入(类微信小程序交互),取代原顶部滑入 -- 📐 **覆盖底部tab栏**:使用 Overlay 确保工具面板层级最高,覆盖底部导航栏 -- 🔘 **固定按钮可见**:底部功能按钮(首页/收藏/设置/关于)随面板一起显示 -- 📳 **震动反馈**:下拉触发工具中心时添加触觉反馈(中等+强烈震动) -- 🔍 **响应式搜索**:搜索框从只读改为可输入,实时搜索匹配工具并显示结果 -- ⚡ **触发灵敏度**:下拉阈值从80px降至50px,阻尼系数从0.5提升至0.8 -- 👆 **下滑关闭**:面板内下滑手势关闭面板,提示文案更新为"下滑关闭" - -#### 修改文件 -- `lib/src/pages/discover/discover_page.dart` — 搜索框响应式搜索、Overlay显示面板、震动反馈、灵敏度调整 -- `lib/src/pages/discover/components/tools_panel_widget.dart` — 从底部滑入动画、下滑关闭手势、遮罩层分离 - - -## [0.97.21] - 2026-04-16 - -### ♻️ 重构 — 发现页代码拆分与工具中心完善 - -#### 变更 -- 📁 **代码拆分**:将 discover_page.dart (原1834行) 拆分为多个文件,每个文件不超过800行 -- 📦 **新建文件夹**:`lib/src/pages/discover/components/` 存放拆分后的组件 -- 🔧 **工具面板组件**:`tools_panel_widget.dart` (792行) — 包含下拉工具中心面板所有UI -- 📊 **分区内容组件**:`discover_sections_widget.dart` (685行) — 包含热门/今天吃什么/推荐三个分区 -- 📄 **主文件精简**:`discover_page.dart` (499行) — 保留页面骨架和状态管理 -- 💾 **备份文件**:`discover_page.dart.bak` 保留原始代码 - -#### 工具中心完善 -- ✅ **上滑关闭**:支持手势上滑关闭工具中心面板 -- 🔍 **搜索框**:基础信息区新增工具搜索功能 -- 📝 **工具详情**:长按工具显示详情弹窗,包含使用次数、联网状态等信息 -- 📂 **分类展示**:所有工具按分类分组显示(烹饪助手/健康营养/数据查询/规划管理) -- 🕐 **浏览记录**:显示最近浏览的菜谱,横向滚动卡片列表 -- 🔘 **底部按钮**:固定功能按钮栏(首页/收藏/设置/关于) -- 📏 **层级修复**:工具中心面板覆盖在底部tab栏之上 - -#### 修改文件 -- `lib/src/pages/discover/discover_page.dart` — 主文件精简,引用新组件 -- `lib/src/pages/discover/components/tools_panel_widget.dart` — 工具面板组件 -- `lib/src/pages/discover/components/discover_sections_widget.dart` — 分区内容组件 -- `lib/src/pages/discover/components/tool_detail_sheet.dart` — 工具详情弹窗 -- `lib/src/pages/discover/components/browse_history_section.dart` — 浏览记录组件 -- `lib/src/pages/discover/discover_page.dart.bak` — 备份 - - -## [0.97.20] - 2026-04-16 - -### ♻️ 重构 — 发现页新增动态工具栏 - -#### 变更 -- 🔄 **发现页面**:新增动态工具栏,显示常用工具快捷入口(按使用次数排序) -- 📊 **智能排序**:工具按 `usageCount` 降序排列,常用工具优先显示 -- 🔧 **工具功能**:点击工具记录使用次数并跳转;"更多"按钮打开完整工具中心面板 -- 💎 **视觉风格**:毛玻璃卡片效果,与 iOS 26 Liquid Glass 风格一致 -- 📍 **位置调整**:工具栏位于搜索框与分段控制之间 - -#### 修改文件 -- `lib/src/pages/discover/discover_page.dart` — 新增 _buildToolsBar、_buildToolShortcut、_buildMoreToolsCard、_navigateToTool 方法 - - -> 📌 已移除较早版本记录(0.97.19及之前),功能已归档至软件特性清单。 +> 📌 已移除较早版本记录(0.98.3及之前),功能已归档至软件特性清单。 diff --git a/docs/api/doc/API_DOC.md b/docs/api/doc/API_DOC.md index 57c7b9f..23389d0 100644 --- a/docs/api/doc/API_DOC.md +++ b/docs/api/doc/API_DOC.md @@ -1,13 +1,18 @@ # 菜谱 API 接口文档 -> **版本**: v3.2.1 -> **更新日期**: 2026-04-13 +> **版本**: v3.3.0 +> **更新日期**: 2026-04-18 > **基础地址**: `http://eat.wktyl.com/api/` --- ## 📝 更新日志 +### v3.3.0 (2026-04-18) +- **新增点餐助手接口**:`kitchen.php` 支持点单CRUD操作,JSON文件存储,SSE实时推送 +- **新增SSE推送端点**:`kitchen_sse.php` 实现订单实时更新推送 +- **新增菜谱分享页面**:`recipe_share.php` 扫码展示菜谱详情,支持统计和管理 + ### v3.2.1 (2026-04-13) - **文档同步更新**:完善发现页接口字段说明 - **分类字段补充**:添加 `id` 字段到分类返回结构 @@ -37,6 +42,9 @@ | `stats_full.php` | 全面统计 | 热门、在线、请求统计 | | `api_check_duplicate.php` | 查重接口 | 菜品、食材、营养成分、内容查重 | | `diagnose.php` | 诊断工具 | 数据库诊断、运维检查 | +| `kitchen.php` | 点餐助手 | 点单CRUD、JSON存储、过期清理 | +| `kitchen_sse.php` | SSE推送 | 订单实时更新推送 | +| `recipe_share.php` | 菜谱分享 | 扫码展示、访问统计、OG标签 | | `cache.php` | 缓存系统 | 工具类 | | `cache_manage.php` | 缓存管理 | 清理、统计、配置 | | `response.php` | 响应格式 | 工具类 | @@ -56,6 +64,9 @@ - [全面统计 stats_full.php](#全面统计-stats_fullphp) - [查重接口 api_check_duplicate.php](#查重接口-api_check_duplicatephp) - [发现页 api_discover.php](#发现页-api_discoverphp) +- [点餐助手 kitchen.php](#点餐助手-kitchenphp) +- [SSE推送 kitchen_sse.php](#sse推送-kitchen_ssephp) +- [菜谱分享 recipe_share.php](#菜谱分享-recipe_sharephp) - [功能扩展指南](#功能扩展指南) - [错误处理](#错误处理) @@ -1793,6 +1804,479 @@ Widget buildCategoryCard(Map category) { --- +## 点餐助手 kitchen.php + +### 📋 接口索引 + +``` +GET kitchen.php?act=index +``` + +**功能**: 获取点餐助手API所有可用端点 + +**客户端实现**: +```dart +final response = await http.get(Uri.parse('$baseUrl/kitchen.php?act=index')); +``` + +--- + +### ➕ 创建点单 + +``` +POST kitchen.php?act=create +Content-Type: application/json +{ + "items": [{"id": "1", "name": "宫保鸡丁", "quantity": 1}], + "tableNo": "A01", + "peopleCount": 2 +} +``` + +**功能**: 创建新点单 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| items | array | 是 | 菜品列表 | +| tableNo | string | 否 | 桌号 | +| peopleCount | int | 否 | 人数 | +| note | string | 否 | 备注 | +| type | int | 否 | 类型:0=用户点餐,1=商家推单 | + +**返回字段**: + +| 字段 | 说明 | +|------|------| +| `id` | 订单唯一ID(自动生成) | +| `orderNo` | 订单号(自动生成,如 OD123456789) | +| `status` | 状态:0=草稿,1=进行中,2=已完成,3=已取消 | +| `createdAt` | 创建时间(ISO 8601格式) | +| `updatedAt` | 更新时间 | +| `createdBy` | 创建者IP | +| `recordCount` | 全局订单计数 | + +**客户端实现**: +```dart +final response = await http.post( + Uri.parse('$baseUrl/kitchen.php?act=create'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({ + 'items': [ + {'id': '1', 'name': '宫保鸡丁', 'quantity': 1, 'price': 28.0} + ], + 'tableNo': 'A01', + 'peopleCount': 2, + }), +); +``` + +--- + +### 📄 获取点单 + +``` +GET kitchen.php?act=get&id=ord_xxx +``` + +**功能**: 根据订单ID获取点单详情 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | string | 是 | 订单ID | + +**客户端实现**: +```dart +final response = await http.get( + Uri.parse('$baseUrl/kitchen.php?act=get&id=$orderId') +); +``` + +--- + +### ✏️ 更新点单 + +``` +POST kitchen.php?act=update +Content-Type: application/json +{ + "id": "ord_xxx", + "items": [...], + "status": 2 +} +``` + +**功能**: 更新现有订单(如修改菜品、更改状态) + +**客户端实现**: +```dart +final response = await http.post( + Uri.parse('$baseUrl/kitchen.php?act=update'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({ + 'id': orderId, + 'status': 2, // 标记为已完成 + }), +); +``` + +--- + +### 📋 点单列表 + +``` +GET kitchen.php?act=list&page=1&limit=20&status=1 +``` + +**功能**: 获取点单列表,支持分页和状态筛选 + +| 参数 | 类型 | 说明 | +|------|------|------| +| page | int | 页码,默认1 | +| limit | int | 每页数量,默认20,最大100 | +| status | int | 状态筛选:0=草稿,1=进行中,2=已完成,3=已取消 | +| type | int | 类型筛选:0=用户点餐,1=商家推单 | + +**返回字段**: + +| 字段 | 说明 | +|------|------| +| `list` | 订单列表 | +| `total` | 总记录数 | +| `page` | 当前页码 | +| `limit` | 每页数量 | +| `pages` | 总页数 | + +**客户端实现**: +```dart +final response = await http.get( + Uri.parse('$baseUrl/kitchen.php?act=list&page=$page&limit=$limit&status=$status') +); +``` + +--- + +### 🗑️ 删除点单 + +``` +GET kitchen.php?act=delete&id=ord_xxx +``` + +**功能**: 删除指定订单 + +**客户端实现**: +```dart +final response = await http.get( + Uri.parse('$baseUrl/kitchen.php?act=delete&id=$orderId') +); +``` + +--- + +### 🧹 清理过期数据 + +``` +GET kitchen.php?act=cleanup&days=30 +``` + +**功能**: 清理超过指定天数的订单数据 + +| 参数 | 类型 | 说明 | +|------|------|------| +| days | int | 过期天数,默认30 | + +**返回字段**: + +| 字段 | 说明 | +|------|------| +| `deleted_count` | 删除的记录数 | +| `remaining_count` | 剩余记录数 | +| `cutoff_date` | 截止日期 | +| `expire_days` | 过期天数设置 | + +**客户端实现**: +```dart +final response = await http.get( + Uri.parse('$baseUrl/kitchen.php?act=cleanup&days=30') +); +``` + +--- + +### 🧨 清空全部数据 + +``` +POST kitchen.php?act=clear_all&confirm=yes +``` + +**功能**: 清空所有点餐数据(需确认参数) + +**客户端实现**: +```dart +final response = await http.post( + Uri.parse('$baseUrl/kitchen.php?act=clear_all&confirm=yes') +); +``` + +--- + +### 📊 统计信息 + +``` +GET kitchen.php?act=stats +``` + +**功能**: 获取点餐统计信息 + +**返回字段**: + +| 字段 | 说明 | +|------|------| +| `total_orders` | 历史总订单数 | +| `today_orders` | 今日订单数 | +| `stored_orders` | 当前存储订单数 | +| `by_status` | 按状态分类的订单数 | +| `by_type` | 按类型分类的订单数 | + +**客户端实现**: +```dart +final response = await http.get( + Uri.parse('$baseUrl/kitchen.php?act=stats') +); +``` + +--- + +## SSE推送 kitchen_sse.php + +### 📡 建立SSE连接 + +``` +GET kitchen_sse.php?order_id=ord_xxx +``` + +**功能**: 建立Server-Sent Events连接,实时接收订单更新推送 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| order_id | string | 否 | 监听的订单ID(不传则监听全局) | +| last_timestamp | float | 否 | 上次已知时间戳 | + +**事件类型**: + +| 事件 | 说明 | 数据 | +|------|------|------| +| `connected` | 连接成功 | 连接信息 | +| `order_update` | 订单更新 | 最新订单数据 | +| `order_deleted` | 订单删除 | 删除的订单ID | +| `global_update` | 全局更新 | 时间戳信息 | +| `heartbeat` | 心跳包 | 时间戳和迭代次数 | +| `close` | 连接关闭 | 关闭原因 | + +**JavaScript 客户端实现**: +```javascript +const evtSource = new EventSource('kitchen_sse.php?order_id=ord_xxx'); + +evtSource.addEventListener('connected', (e) => { + console.log('SSE连接已建立', JSON.parse(e.data)); +}); + +evtSource.addEventListener('order_update', (e) => { + const order = JSON.parse(e.data); + console.log('订单更新:', order); + updateOrderUI(order); +}); + +evtSource.addEventListener('heartbeat', (e) => { + console.log('心跳包', JSON.parse(e.data)); +}); + +evtSource.addEventListener('close', (e) => { + console.log('连接关闭,准备重连'); + evtSource.close(); + setTimeout(connectSSE, 3000); +}); +``` + +**Dart 客户端实现**: +```dart +import 'package:sse/sse_client.dart'; + +final client = SseClient(Uri.parse('$baseUrl/kitchen_sse.php?order_id=$orderId')); +client.messages.listen((message) { + final data = json.decode(message); + if (data['event'] == 'order_update') { + updateOrderUI(data['data']); + } +}); +``` + +**注意事项**: +- SSE连接最长保持2分钟,超时后自动关闭 +- 客户端断开后需重新连接 +- 心跳包每10秒发送一次 +- 订单更新通过 `kitchen.php` 的写操作触发 + +--- + +## 菜谱分享 recipe_share.php + +### 📱 分享页面 + +``` +GET recipe_share.php?code=CP032892 +GET recipe_share.php?id=32892 +``` + +**功能**: 渲染美观的菜谱分享页面(iOS风格,支持深色模式) + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| code | string | 二选一 | 菜谱编码(如 CP032892) | +| id | int | 二选一 | 菜谱ID | + +**页面特性**: +- 🎨 iOS风格设计,深色模式自适应 +- 📊 自动记录访问统计 +- 🏷️ OG标签支持(微信/社交媒体分享预览) +- 📱 响应式设计,移动端适配 + +**客户端实现**: +```dart +// 生成分享链接 +String generateShareLink(Recipe recipe) { + if (recipe.code != null) { + return '$baseUrl/recipe_share.php?code=${recipe.code}'; + } + return '$baseUrl/recipe_share.php?id=${recipe.id}'; +} + +// 在WebView中打开 +WebView( + initialUrl: generateShareLink(recipe), +); +``` + +--- + +### 📊 分享统计 + +``` +GET recipe_share.php?act=stats +``` + +**功能**: 获取菜谱分享的访问统计 + +**返回字段**: + +| 字段 | 说明 | +|------|------| +| `total_views` | 总访问量 | +| `today_views` | 今日访问量 | +| `today_date` | 统计日期 | +| `recipes` | 各菜谱访问统计(按菜谱ID分组) | + +**返回示例**: +```json +{ + "code": 200, + "data": { + "total_views": 1250, + "today_views": 85, + "today_date": "2026-04-18", + "recipes": { + "r_32892": { + "id": 32892, + "title": "三色玉米沙拉", + "code": "CP032892", + "views": 120, + "first_access": "2026-04-15T10:30:00+08:00", + "last_access": "2026-04-18T14:20:00+08:00" + } + } + } +} +``` + +**客户端实现**: +```dart +final response = await http.get( + Uri.parse('$baseUrl/recipe_share.php?act=stats') +); +``` + +--- + +### 📝 访问日志 + +``` +GET recipe_share.php?act=log&page=1&limit=20 +``` + +**功能**: 获取分享访问日志(保留最近200条) + +| 参数 | 类型 | 说明 | +|------|------|------| +| page | int | 页码,默认1 | +| limit | int | 每页数量,默认20,最大50 | + +**返回字段**: + +| 字段 | 说明 | +|------|------| +| `list` | 日志列表 | +| `total` | 总日志数 | +| `page` | 当前页码 | +| `limit` | 每页数量 | + +**日志字段**: + +| 字段 | 说明 | +|------|------| +| `time` | 访问时间 | +| `recipe_id` | 菜谱ID | +| `title` | 菜谱标题 | +| `code` | 菜谱编码 | +| `ip` | 访问者IP | +| `ua` | 用户代理 | +| `referer` | 来源页面 | + +**客户端实现**: +```dart +final response = await http.get( + Uri.parse('$baseUrl/recipe_share.php?act=log&page=$page&limit=$limit') +); +``` + +--- + +### 🔌 JSON数据接口 + +``` +GET recipe_share.php?act=api&code=CP032892 +GET recipe_share.php?act=api&id=32892 +``` + +**功能**: 返回菜谱JSON数据(通过调用 api.php 获取,不直接查数据库) + +**客户端实现**: +```dart +final response = await http.get( + Uri.parse('$baseUrl/recipe_share.php?act=api&code=$code') +); +``` + +--- + +### 📖 接口说明 + +``` +GET recipe_share.php?act=index +``` + +**功能**: 获取菜谱分享API所有可用端点 + +--- + ## 功能扩展指南 本章节详细介绍每个接口返回字段的具体用途、应用场景和可实现的功能。 diff --git a/docs/api/recipe_share.php b/docs/api/recipe_share.php new file mode 100644 index 0000000..0cfcf67 --- /dev/null +++ b/docs/api/recipe_share.php @@ -0,0 +1,1401 @@ + $value) { + if (!isset($_GET[$key])) { + $_GET[$key] = $value; + } + } +} + +$act = strtolower(trim($_GET['act'] ?? 'share')); + +// ─── 数据目录配置(与kitchen.php共享) ─── +$dataDir = dirname(__FILE__) . '/cache/kitchen/'; +if (!is_dir($dataDir)) { + if (!@mkdir($dataDir, 0755, true)) { + $tmpDir = sys_get_temp_dir() . '/kitchen/'; + if (!is_dir($tmpDir)) { + @mkdir($tmpDir, 0755, true); + } + $dataDir = $tmpDir; + } +} +if (!is_writable($dataDir)) { + $tmpDir = sys_get_temp_dir() . '/kitchen/'; + if (!is_dir($tmpDir)) { + @mkdir($tmpDir, 0755, true); + } + $dataDir = $tmpDir; +} + +$statsFile = $dataDir . 'share_stats.json'; +$logFile = $dataDir . 'access_log.json'; + +// ─── 路由分发 ─── +switch ($act) { + case 'create': + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(create_recipe_share(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + break; + case 'update': + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(update_recipe_share(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + break; + case 'delete': + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(delete_recipe_share(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + break; + case 'list': + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(list_recipe_data(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + break; + case 'cleanup': + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(cleanup_expired_recipes(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + break; + case 'stats': + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(get_share_stats(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + break; + case 'log': + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(get_access_log(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + break; + case 'api': + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(fetch_recipe_data(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + break; + case 'index': + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(array( + 'code' => 200, + 'message' => '🍳 菜谱分享页面', + 'data' => array( + 'version' => '1.3.0', + 'description' => '菜谱分享页面,本地JSON存储,支持CRUD和统计,数据3天后自动过期删除,完整数据传输', + 'endpoints' => array( + 'share' => '?code=CP00001 或 ?id=123', + 'api' => '?act=api&code=CP00001 (JSON数据)', + 'create' => 'POST ?act=create {recipe JSON}', + 'update' => 'POST ?act=update {recipe JSON}', + 'delete' => 'GET ?act=delete&id=xxx', + 'list' => 'GET ?act=list', + 'cleanup' => 'GET ?act=cleanup (清理过期数据)', + 'stats' => '?act=stats (分享统计)', + 'log' => '?act=log (访问日志)', + 'index' => '?act=index (接口说明)', + ), + 'storage' => 'JSON文件 (' . $dataDir . 'recipe_*.json)', + 'expire_policy' => '数据3天后自动过期删除,读取时自动检查并清理', + 'display' => '完整显示:封面/标题/简介/标签/食材/营养/过敏原/步骤', + ), + ), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + break; + case 'share': + default: + render_share_page(); + break; +} + +exit; + +// ══════════════════════════════════════════════════════════════ +// 数据获取函数 +// ══════════════════════════════════════════════════════════════ + +/** + * 读取菜谱分享数据(本地JSON文件) + * 自动检查过期,过期则删除并返回null + */ +function read_recipe_data($id) { + global $dataDir; + $file = $dataDir . 'recipe_' . (int)$id . '.json'; + if (!file_exists($file)) { + return null; + } + + // 检查是否过期 + if (is_recipe_expired($file)) { + @unlink($file); + return null; + } + + $content = file_get_contents($file); + $data = json_decode($content, true); + return is_array($data) ? $data : null; +} + +/** + * 检查菜谱数据是否过期 + */ +function is_recipe_expired($file) { + if (!file_exists($file)) return true; + + $content = file_get_contents($file); + $data = json_decode($content, true); + if (!is_array($data)) return true; + + $expiresAt = $data['expiresAt'] ?? null; + if (empty($expiresAt)) { + // 没有过期时间,默认3天后过期(从创建时间算起) + $createdAt = $data['createdAt'] ?? $data['updatedAt'] ?? ''; + if (empty($createdAt)) return false; + $expiresAt = date('c', strtotime($createdAt . ' +3 days')); + } + + return strtotime($expiresAt) < time(); +} + +/** + * 清理所有过期的菜谱分享数据 + */ +function cleanup_expired_recipes() { + global $dataDir; + $files = glob($dataDir . 'recipe_*.json'); + if ($files === false) return array('deleted' => 0, 'errors' => array()); + + $deleted = 0; + $errors = array(); + + foreach ($files as $file) { + if (is_recipe_expired($file)) { + if (@unlink($file)) { + $deleted++; + } else { + $errors[] = basename($file); + } + } + } + + return array( + 'deleted' => $deleted, + 'errors' => $errors, + ); +} + +/** + * 写入菜谱分享数据(本地JSON文件) + * 注意:数据3天后自动过期删除 + */ +function write_recipe_data($id, $data) { + global $dataDir; + $file = $dataDir . 'recipe_' . (int)$id . '.json'; + + // 添加过期时间(3天后) + $data['expiresAt'] = date('c', strtotime('+3 days')); + $data['updatedAt'] = date('c'); + + $fp = fopen($file, 'c'); + if (flock($fp, LOCK_EX)) { + ftruncate($fp, 0); + rewind($fp); + fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + flock($fp, LOCK_UN); + } + fclose($fp); +} + +/** + * 删除菜谱分享数据 + */ +function delete_recipe_data($id) { + global $dataDir; + $file = $dataDir . 'recipe_' . (int)$id . '.json'; + if (file_exists($file)) { + return @unlink($file); + } + return false; +} + +/** + * 获取菜谱分享列表 + */ +function list_recipe_data() { + global $dataDir; + $recipes = array(); + $files = glob($dataDir . 'recipe_*.json'); + if ($files === false) return $recipes; + + foreach ($files as $file) { + $content = file_get_contents($file); + $data = json_decode($content, true); + if (is_array($data)) { + $recipes[] = $data; + } + } + + // 按更新时间倒序 + usort($recipes, function($a, $b) { + $timeA = $a['updatedAt'] ?? $a['createdAt'] ?? ''; + $timeB = $b['updatedAt'] ?? $b['createdAt'] ?? ''; + return strcmp($timeB, $timeA); + }); + + return $recipes; +} + +/** + * 将code解析为ID(支持多种格式) + * 支持格式: CP00001, CP123, cp123, cpxxxx, 123 + */ +function resolve_code_to_id($code) { + // 支持 CP/cp/cpxxxx 格式,提取数字部分 + if (preg_match('/^CPX?(\d+)$/i', $code, $matches)) { + return (int)$matches[1]; + } + return (int)$code; +} + +/** + * 获取API基础URL(用于页面链接) + */ +function get_api_base_url() { + $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? 'eat.wktyl.com'; + return $protocol . '://' . $host; +} + +/** + * 通过本地JSON文件获取菜谱数据 + */ +function fetch_recipe_data() { + $code = trim($_GET['code'] ?? ''); + $id = (int)($_GET['id'] ?? 0); + + if (empty($code) && $id <= 0) { + return array('code' => 400, 'message' => '缺少 code 或 id 参数'); + } + + $resolvedId = $id > 0 ? $id : resolve_code_to_id($code); + $recipe = read_recipe_data($resolvedId); + + if ($recipe === null) { + return array('code' => 404, 'message' => '菜谱不存在'); + } + + record_share_access($recipe, $code, $id); + + return array( + 'code' => 200, + 'message' => 'success', + 'data' => $recipe, + ); +} + +/** + * 获取菜谱完整数据(用于页面渲染) + */ +function fetch_recipe_full($id) { + $recipe = read_recipe_data((int)$id); + if ($recipe === null) return array(); + + return $recipe; +} + +/** + * 创建菜谱分享数据 + * POST ?act=create + * Body: {recipe JSON} + */ +function create_recipe_share() { + $recipeJson = $_GET['recipe'] ?? $_GET['data'] ?? null; + + if ($recipeJson === null) { + $rawBody = file_get_contents('php://input'); + $recipeData = json_decode($rawBody, true); + } else { + $recipeData = json_decode($recipeJson, true); + } + + if (!is_array($recipeData)) { + return array('code' => 400, 'message' => '无效的菜谱数据,需要JSON格式'); + } + + // 补全字段 + $id = (int)($recipeData['id'] ?? 0); + if ($id <= 0) { + return array('code' => 400, 'message' => '缺少菜谱ID'); + } + + if (empty($recipeData['createdAt'])) { + $recipeData['createdAt'] = date('c'); + } + $recipeData['updatedAt'] = date('c'); + $recipeData['createdBy'] = get_client_ip(); + + write_recipe_data($id, $recipeData); + + return array( + 'code' => 200, + 'message' => '创建成功', + 'data' => $recipeData, + ); +} + +/** + * 更新菜谱分享数据 + * POST ?act=update + * Body: {recipe JSON with id} + */ +function update_recipe_share() { + $recipeJson = $_GET['recipe'] ?? $_GET['data'] ?? null; + + if ($recipeJson === null) { + $rawBody = file_get_contents('php://input'); + $recipeData = json_decode($rawBody, true); + } else { + $recipeData = json_decode($recipeJson, true); + } + + if (!is_array($recipeData) || empty($recipeData['id'])) { + return array('code' => 400, 'message' => '无效的菜谱数据,缺少id字段'); + } + + $id = (int)$recipeData['id']; + $existing = read_recipe_data($id); + + if ($existing === null) { + return array('code' => 404, 'message' => '菜谱不存在'); + } + + // 合并数据 + $recipeData = array_merge($existing, $recipeData); + $recipeData['updatedAt'] = date('c'); + + write_recipe_data($id, $recipeData); + + return array( + 'code' => 200, + 'message' => '更新成功', + 'data' => $recipeData, + ); +} + +/** + * 删除菜谱分享数据 + * GET ?act=delete&id=xxx + */ +function delete_recipe_share() { + $id = (int)($_GET['id'] ?? 0); + if ($id <= 0) { + return array('code' => 400, 'message' => '缺少菜谱ID参数'); + } + + if (!delete_recipe_data($id)) { + return array('code' => 404, 'message' => '菜谱不存在'); + } + + return array( + 'code' => 200, + 'message' => '删除成功', + 'data' => array('deleted_id' => $id), + ); +} + +// ══════════════════════════════════════════════════════════════ +// 统计和日志函数 +// ══════════════════════════════════════════════════════════════ + +/** + * 记录分享访问 + */ +function record_share_access($recipe, $code, $id) { + global $statsFile, $logFile; + + $recipeId = $recipe['id'] ?? $id; + $recipeTitle = $recipe['title'] ?? '未知菜谱'; + + // 更新统计 + $stats = read_json_file($statsFile, array( + 'total_views' => 0, + 'today_views' => 0, + 'today_date' => date('Y-m-d'), + 'recipes' => array(), + )); + + $today = date('Y-m-d'); + if ($stats['today_date'] !== $today) { + $stats['today_views'] = 0; + $stats['today_date'] = $today; + } + + $stats['total_views']++; + $stats['today_views']++; + + $key = 'r_' . $recipeId; + if (!isset($stats['recipes'][$key])) { + $stats['recipes'][$key] = array( + 'id' => $recipeId, + 'title' => $recipeTitle, + 'code' => $code, + 'views' => 0, + 'first_access' => date('c'), + 'last_access' => date('c'), + ); + } + $stats['recipes'][$key]['views']++; + $stats['recipes'][$key]['last_access'] = date('c'); + if (!empty($recipeTitle) && $recipeTitle !== '未知菜谱') { + $stats['recipes'][$key]['title'] = $recipeTitle; + } + + write_json_file($statsFile, $stats); + + // 记录访问日志(保留最近200条) + $log = read_json_file($logFile, array()); + array_unshift($log, array( + 'time' => date('c'), + 'recipe_id' => $recipeId, + 'title' => $recipeTitle, + 'code' => $code, + 'ip' => get_client_ip(), + 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '', + 'referer' => $_SERVER['HTTP_REFERER'] ?? '', + )); + $log = array_slice($log, 0, 200); + write_json_file($logFile, $log); +} + +/** + * 获取分享统计 + */ +function get_share_stats() { + global $statsFile; + $stats = read_json_file($statsFile, array( + 'total_views' => 0, + 'today_views' => 0, + 'today_date' => date('Y-m-d'), + 'recipes' => array(), + )); + return array( + 'code' => 200, + 'data' => $stats, + ); +} + +/** + * 获取访问日志 + */ +function get_access_log() { + global $logFile; + $log = read_json_file($logFile, array()); + $page = max(1, (int)($_GET['page'] ?? 1)); + $limit = min(50, max(1, (int)($_GET['limit'] ?? 20))); + $offset = ($page - 1) * $limit; + return array( + 'code' => 200, + 'data' => array( + 'list' => array_slice($log, $offset, $limit), + 'total' => count($log), + 'page' => $page, + 'limit' => $limit, + ), + ); +} + +// ══════════════════════════════════════════════════════════════ +// 工具函数 +// ══════════════════════════════════════════════════════════════ + +function read_json_file($file, $default = array()) { + if (!file_exists($file)) return $default; + $content = file_get_contents($file); + $data = json_decode($content, true); + return is_array($data) ? $data : $default; +} + +function write_json_file($file, $data) { + $fp = fopen($file, 'c'); + if (flock($fp, LOCK_EX)) { + ftruncate($fp, 0); + rewind($fp); + fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + flock($fp, LOCK_UN); + } + fclose($fp); +} + +function get_client_ip() { + if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + $ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']); + return trim($ips[0]); + } + if (!empty($_SERVER['HTTP_X_REAL_IP'])) { + return $_SERVER['HTTP_X_REAL_IP']; + } + return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; +} + +function safe_html($str) { + return htmlspecialchars($str ?? '', ENT_QUOTES, 'UTF-8'); +} + +function format_time_ago($timestamp) { + if (empty($timestamp)) return ''; + $now = time(); + $ts = is_numeric($timestamp) ? (int)$timestamp : strtotime($timestamp); + $diff = $now - $ts; + if ($diff < 60) return '刚刚'; + if ($diff < 3600) return floor($diff / 60) . '分钟前'; + if ($diff < 86400) return floor($diff / 3600) . '小时前'; + if ($diff < 604800) return floor($diff / 86400) . '天前'; + return date('Y-m-d', $ts); +} + +// ══════════════════════════════════════════════════════════════ +// 页面渲染 +// ══════════════════════════════════════════════════════════════ + +/** + * 渲染分享页面 + */ +function render_share_page() { + $code = trim($_GET['code'] ?? ''); + $id = (int)($_GET['id'] ?? 0); + + $recipe = array(); + $error = ''; + + if (!empty($code) || $id > 0) { + $resolvedId = $id > 0 ? $id : resolve_code_to_id($code); + $recipe = fetch_recipe_full($resolvedId); + if (!empty($recipe)) { + record_share_access($recipe, $code, $id); + } else { + $error = '菜谱不存在或暂时无法加载'; + } + } else { + $error = '缺少菜谱参数'; + } + + // 提取数据用于显示 + $title = !empty($recipe) ? safe_html($recipe['title'] ?? '菜谱分享') : '🍳 菜谱分享'; + $cover = !empty($recipe) ? ($recipe['cover'] ?? '') : ''; + $intro = !empty($recipe) ? ($recipe['intro'] ?? '') : ''; + $content = !empty($recipe) ? ($recipe['content'] ?? '') : ''; + $categoryName = !empty($recipe) ? ($recipe['category_name'] ?? ($recipe['categoryName'] ?? '')) : ''; + $recipeCode = !empty($recipe) ? ($recipe['code'] ?? $code) : $code; + $viewCount = !empty($recipe) ? (($recipe['statistics']['views'] ?? 0) ?: ($recipe['view_count'] ?? 0)) : 0; + $likeCount = !empty($recipe) ? (($recipe['statistics']['likes'] ?? 0) ?: ($recipe['like_count'] ?? 0)) : 0; + $ratingScore = !empty($recipe) ? (($recipe['rating']['score'] ?? 0) ?: 0) : 0; + $ratingNums = !empty($recipe) ? (($recipe['rating']['nums'] ?? 0) ?: 0) : 0; + $author = !empty($recipe) ? ($recipe['author'] ?? null) : null; + $tags = !empty($recipe) ? ($recipe['tags'] ?? array()) : array(); + $ingredients = !empty($recipe) ? ($recipe['ingredients'] ?? array()) : array(); + $nutrition = !empty($recipe) ? ($recipe['nutrition'] ?? null) : null; + $meta = !empty($recipe) ? ($recipe['meta'] ?? null) : null; + $allergens = !empty($recipe) ? ($recipe['allergens'] ?? array()) : array(); + + $queryTime = round((microtime(true) - $GLOBALS['startTime']) * 1000, 2); + + echo ' + + + + +' . $title . ' - 小妈厨房 + + + + + +' . (!empty($cover) ? '' : '') . ' + + + + + +
'; + + if (!empty($error)) { + echo ' + +
+
😕
+
无法加载菜谱
+
' . safe_html($error) . '
+ + 🍳 返回首页 + +
'; + } else { + $starsHtml = ''; + $fullStars = floor($ratingScore); + $halfStar = ($ratingScore - $fullStars) >= 0.5; + for ($i = 0; $i < 5; $i++) { + if ($i < $fullStars) { + $starsHtml .= ''; + } elseif ($i === $fullStars && $halfStar) { + $starsHtml .= ''; + } else { + $starsHtml .= ''; + } + } + + echo ' + + +
'; + if (!empty($cover)) { + echo '' . $title . ''; + } else { + echo '
+
🍳
+
' . $title . '
+
'; + } + echo ' +
'; + if (!empty($categoryName)) { + echo '' . safe_html($categoryName) . ''; + } + echo '

' . $title . '

+
+
+ +
'; + if ($viewCount > 0) { + echo ' +
+
👁️
+
+
浏览量
+
' . number_format($viewCount) . ' 次
+
+
'; + } + if ($ratingScore > 0) { + echo ' +
+
+
+
评分
+
+ ' . $starsHtml . ' + ' . number_format($ratingScore, 1) . ' 分' . ($ratingNums > 0 ? ' · ' . $ratingNums . ' 人评' : '') . ' +
+
+
'; + } + if (!empty($recipeCode)) { + echo ' +
+
🔢
+
+
菜谱编码
+
' . safe_html($recipeCode) . '
+
+
'; + } + + // 显示作者信息 + if (!empty($author) && is_array($author)) { + $authorName = $author['name'] ?? ($author['nickname'] ?? ''); + $authorAvatar = $author['avatar'] ?? ''; + if (!empty($authorName)) { + echo ' +
+
👨‍🍳
+
+
作者
+
' . safe_html($authorName) . '
+
+
'; + } + } + + // 显示烹饪时间和难度 + if (!empty($meta) && is_array($meta)) { + $difficulty = $meta['difficulty'] ?? ''; + $time = $meta['time'] ?? ''; + if (!empty($time)) { + echo ' +
+
⏱️
+
+
烹饪时间
+
' . safe_html($time) . '
+
+
'; + } + if (!empty($difficulty)) { + echo ' +
+
📊
+
+
难度
+
' . safe_html($difficulty) . '
+
+
'; + } + } + + echo ' +
'; + + // 显示简介 + if (!empty($intro)) { + echo ' +
+

📝 简介

+

' . nl2br(safe_html($intro)) . '

+
'; + } + + // 显示标签 + if (!empty($tags) && is_array($tags) && count($tags) > 0) { + echo ' +
+

🏷️ 标签

+
'; + foreach ($tags as $tag) { + $tagName = is_array($tag) ? ($tag['name'] ?? '') : $tag; + if (!empty($tagName)) { + echo '' . safe_html($tagName) . ''; + } + } + echo ' +
+
'; + } + + // 显示食材列表 + if (!empty($ingredients) && is_array($ingredients) && count($ingredients) > 0) { + echo ' +
+

🥗 食材清单

+
'; + foreach ($ingredients as $ing) { + $ingName = is_array($ing) ? ($ing['name'] ?? '') : $ing; + $ingAmount = is_array($ing) ? ($ing['amount'] ?? '') : ''; + $ingUnit = is_array($ing) ? ($ing['unit'] ?? '') : ''; + if (!empty($ingName)) { + echo ' +
+ ' . safe_html($ingName) . ' + ' . safe_html(trim($ingAmount . ' ' . $ingUnit)) . ' +
'; + } + } + echo ' +
+
'; + } + + // 显示营养信息 + if (!empty($nutrition) && is_array($nutrition)) { + $hasNutrition = false; + $nutritionItems = array(); + $nutritionFields = array( + 'calories' => array('label' => '热量', 'unit' => 'kcal', 'icon' => '🔥'), + 'protein' => array('label' => '蛋白质', 'unit' => 'g', 'icon' => '💪'), + 'fat' => array('label' => '脂肪', 'unit' => 'g', 'icon' => '🧈'), + 'carbs' => array('label' => '碳水', 'unit' => 'g', 'icon' => '🍞'), + 'fiber' => array('label' => '膳食纤维', 'unit' => 'g', 'icon' => '🥬'), + ); + foreach ($nutritionFields as $key => $info) { + $value = $nutrition[$key] ?? null; + if ($value !== null && $value !== '') { + $hasNutrition = true; + $nutritionItems[] = '
' . $info['icon'] . '' . $info['label'] . '' . $value . ' ' . $info['unit'] . '
'; + } + } + if ($hasNutrition) { + echo ' +
+

📊 营养成分

+
' . implode('', $nutritionItems) . '
+
'; + } + } + + // 显示过敏原警告 + if (!empty($allergens) && is_array($allergens) && count($allergens) > 0) { + echo ' +
+

⚠️ 过敏原提示

+
'; + foreach ($allergens as $allergen) { + echo '' . safe_html($allergen) . ''; + } + echo ' +
+
'; + } + + // 显示步骤内容 + if (!empty($content)) { + echo ' +
+

👨‍🍳 制作步骤

+
' . nl2br(safe_html($content)) . '
+
'; + } + + echo ' + '; + } + + echo ' + +
+ +'; +} diff --git a/lib/src/app_binding.dart b/lib/src/app_binding.dart index 5b3e04d..4c640d3 100644 --- a/lib/src/app_binding.dart +++ b/lib/src/app_binding.dart @@ -27,6 +27,7 @@ import 'package:mom_kitchen/src/services/core/app_service.dart'; import 'package:mom_kitchen/src/services/ui/theme_service.dart'; import 'package:mom_kitchen/src/services/data/offline_service.dart'; import 'package:mom_kitchen/src/services/data/data_export_service.dart'; +import 'package:mom_kitchen/src/services/user/taste_preference_service.dart'; /// 全局Binding - 应用启动时注册所有全局控制器和服务 /// 所有 permanent:true 的控制器在此统一管理,路由级Binding禁止重复注册 @@ -55,6 +56,9 @@ class AppBinding extends Bindings { // --- 数据导出服务 --- Get.put(DataExportService(), permanent: true); + // --- 口味偏好服务 --- + Get.put(TastePreferenceService(), permanent: true); + // --- 主题与个性化(首屏必需) --- if (!Get.isRegistered()) { Get.put(ThemeService.instance, permanent: true); diff --git a/lib/src/config/app_routes.dart b/lib/src/config/app_routes.dart index 60d86a7..af49891 100644 --- a/lib/src/config/app_routes.dart +++ b/lib/src/config/app_routes.dart @@ -51,6 +51,7 @@ import 'package:mom_kitchen/src/pages/tools/cooking_tips_list_page.dart'; 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/social/email_history_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'; @@ -117,6 +118,7 @@ class AppRoutes { static const String about = '/about'; static const String references = '/references'; static const String privacyPolicy = '/privacy-policy'; + static const String learnUs = '/learn-us'; static const String guide = '/guide'; static const String nutritionRecipeList = '/nutrition-recipe-list'; static const String miniCard = '/mini-card'; @@ -184,6 +186,11 @@ class AppRoutes { page: () => const PrivacyPolicyPage(), middlewares: [PageStandardsMiddleware()], ), + GetPage( + name: learnUs, + page: () => const LearnUsPage(), + middlewares: [PageStandardsMiddleware()], + ), GetPage( name: dataExport, page: () => const DataExportPage(), @@ -407,7 +414,7 @@ class AppRoutes { : null); return CategoryBrowsePage( category: category, - title: args?['title'] ?? '�������', + title: args?['title'] ?? '分类浏览', loadRecipesDirectly: args?['loadRecipesDirectly'] ?? false, isIngredient: args?['isIngredient'] ?? false, ); @@ -646,6 +653,42 @@ class AppRoutes { ], builder: () => const FeedbackPage(), ), + PageInfo( + route: about, + name: 'About Page', + description: '关于页面', + requiredStandards: const [ + StandardCheck.themeColors, + StandardCheck.textColors, + StandardCheck.fontSize, + StandardCheck.darkMode, + ], + builder: () => const AboutPage(), + ), + PageInfo( + route: privacyPolicy, + name: 'Privacy Policy Page', + description: '隐私政策页面', + requiredStandards: const [ + StandardCheck.themeColors, + StandardCheck.textColors, + StandardCheck.fontSize, + StandardCheck.darkMode, + ], + builder: () => const PrivacyPolicyPage(), + ), + PageInfo( + route: guide, + name: 'Guide Page', + description: '使用指南页面', + requiredStandards: const [ + StandardCheck.themeColors, + StandardCheck.textColors, + StandardCheck.fontSize, + StandardCheck.darkMode, + ], + builder: () => const GuidePage(), + ), PageInfo( route: main, name: 'Main Tab View', @@ -1248,6 +1291,18 @@ class AppRoutes { ], builder: () => const FarmAchievementPage(), ), + PageInfo( + route: learnUs, + name: 'Learn Us Page', + description: '了解我们页面', + requiredStandards: const [ + StandardCheck.themeColors, + StandardCheck.textColors, + StandardCheck.fontSize, + StandardCheck.darkMode, + ], + builder: () => const LearnUsPage(), + ), ]); } } diff --git a/lib/src/controllers/farm/farm_game_controller.dart b/lib/src/controllers/farm/farm_game_controller.dart index f87791e..5903710 100644 --- a/lib/src/controllers/farm/farm_game_controller.dart +++ b/lib/src/controllers/farm/farm_game_controller.dart @@ -1,7 +1,9 @@ // 农场游戏核心控制器 // 管理游戏逻辑:种植、生长、浇水、收获、升级、成就 // 2026-04-18 | 优化:生长计时器间隔改为30秒;添加应用前后台生命周期管理 +// 2026-04-18 | 修复:消息限流(5秒2次/10秒3次/去重);ActionSheet操作后消费对话框 import 'dart:async'; +import 'dart:collection'; import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; import 'package:get/get.dart'; @@ -13,6 +15,7 @@ import 'package:mom_kitchen/src/models/farm/achievement_registry.dart'; import 'package:mom_kitchen/src/services/data/hive_service.dart'; import 'package:mom_kitchen/src/config/farm_config.dart'; import 'package:mom_kitchen/src/services/log/logger_service.dart'; +import 'package:mom_kitchen/src/services/ui/toast_service.dart'; class FarmGameController extends GetxController with WidgetsBindingObserver { final Rx player = FarmPlayer.createDefault('').obs; @@ -23,6 +26,12 @@ class FarmGameController extends GetxController with WidgetsBindingObserver { bool _isInitialized = false; DateTime? _pausedTime; + final Queue _toastTimestamps = Queue(); + String? _lastToastMessage; + + static const int _maxIn5s = 2; + static const int _maxIn10s = 3; + @override void onInit() { super.onInit(); @@ -62,6 +71,29 @@ class FarmGameController extends GetxController with WidgetsBindingObserver { } } + // ==================== 消息限流 ==================== + + void _showToast(String message, {ToastType type = ToastType.info}) { + final now = DateTime.now(); + _toastTimestamps.removeWhere((t) => now.difference(t).inSeconds > 10); + + final countIn5s = _toastTimestamps + .where((t) => now.difference(t).inSeconds <= 5) + .length; + final countIn10s = _toastTimestamps.length; + + if (message == _lastToastMessage) return; + if (countIn5s >= _maxIn5s || countIn10s >= _maxIn10s) return; + + _toastTimestamps.add(now); + _lastToastMessage = message; + Future.delayed(const Duration(seconds: 3), () { + if (_lastToastMessage == message) _lastToastMessage = null; + }); + + ToastService.show(message: message, type: type); + } + // ==================== 数据加载 ==================== void _loadData() { @@ -161,16 +193,16 @@ class FarmGameController extends GetxController with WidgetsBindingObserver { Future plantCrop({required int landId, required String cropId}) async { final land = lands.firstWhereOrNull((l) => l.landId == landId); if (land == null) { - Get.snackbar('错误', '土地不存在'); + _showToast('土地不存在', type: ToastType.error); return; } if (land.cropId != null) { - Get.snackbar('提示', '这块土地已经种植了作物'); + _showToast('这块土地已经种植了作物', type: ToastType.warning); return; } if (!land.isUnlocked) { - Get.snackbar('提示', '这块土地还未解锁'); + _showToast('这块土地还未解锁', type: ToastType.warning); return; } @@ -180,11 +212,10 @@ class FarmGameController extends GetxController with WidgetsBindingObserver { .firstOrNull; if (seedItem == null || seedItem.quantity <= 0) { - Get.snackbar('提示', '种子数量不足,请前往商店购买'); + _showToast('种子数量不足,请前往商店购买 🛒', type: ToastType.warning); return; } - // 消耗种子 seedItem.quantity--; if (seedItem.quantity == 0) { inventory.remove(seedItem); @@ -193,7 +224,6 @@ class FarmGameController extends GetxController with WidgetsBindingObserver { _saveInventoryItem(seedItem); } - // 种植 land.cropId = cropId; land.plantTime = DateTime.now(); land.growthStage = 0; @@ -208,7 +238,7 @@ class FarmGameController extends GetxController with WidgetsBindingObserver { lands.refresh(); final crop = CropRegistry.getById(cropId); - Get.snackbar('🌱 种植成功', '已开始种植${crop?.name}'); + _showToast('🌱 已种植${crop?.name}', type: ToastType.success); LoggerService().info('Planted ${crop?.name} on land $landId'); } @@ -216,11 +246,11 @@ class FarmGameController extends GetxController with WidgetsBindingObserver { Future waterLand(int landId) async { final land = lands.firstWhereOrNull((l) => l.landId == landId); if (land == null) { - Get.snackbar('错误', '土地不存在'); + _showToast('土地不存在', type: ToastType.error); return; } if (!land.needWater || land.isWithered || land.isReady) { - Get.snackbar('提示', '这块土地不需要浇水'); + _showToast('这块土地不需要浇水', type: ToastType.warning); return; } @@ -229,7 +259,7 @@ class FarmGameController extends GetxController with WidgetsBindingObserver { await _saveLand(land); lands.refresh(); - Get.snackbar('💧 浇水成功', '作物生长速度已提升'); + _showToast('💧 浇水成功,生长速度提升', type: ToastType.success); LoggerService().info('Watered land $landId'); } @@ -241,7 +271,7 @@ class FarmGameController extends GetxController with WidgetsBindingObserver { return; } if (!land.isReady) { - Get.snackbar('提示', '作物还未成熟'); + _showToast('作物还未成熟', type: ToastType.warning); return; } @@ -293,9 +323,9 @@ class FarmGameController extends GetxController with WidgetsBindingObserver { // 检查成就 _checkAchievements('totalHarvest', player.value.totalHarvest); - Get.snackbar( - '🎉 收获成功', - '获得 ${crop.harvestPrice} 金币,+${crop.harvestExp} 经验', + _showToast( + '🎉 收获 ${crop.harvestPrice}💰 +${crop.harvestExp}EXP', + type: ToastType.success, ); LoggerService().info('Harvested ${crop.name} from land $landId'); } @@ -319,22 +349,23 @@ class FarmGameController extends GetxController with WidgetsBindingObserver { await _saveLand(land); lands.refresh(); - Get.snackbar('🧹 清理完成', '土地已恢复'); + _showToast('🧹 土地已恢复', type: ToastType.success); } // ==================== 升级和成就 ==================== void _checkLevelUp() { - while (player.value.experience >= player.value.expToNextLevel) { + int maxLevelsPerCheck = 5; + while (player.value.experience >= player.value.expToNextLevel && + maxLevelsPerCheck > 0) { player.value.experience -= player.value.expToNextLevel; player.value.level++; + maxLevelsPerCheck--; - // 升级奖励 final goldReward = player.value.level * 50; player.value.gold += goldReward; player.value.diamond += 5; - // 解锁新作物 final newCrops = CropRegistry.getAvailableForLevel(player.value.level); for (final crop in newCrops) { if (!player.value.unlockedCrops.contains(crop.id)) { @@ -344,14 +375,13 @@ class FarmGameController extends GetxController with WidgetsBindingObserver { savePlayer(); - Get.snackbar( - '🎊 升级!', - '当前等级:Lv.${player.value.level}\n奖励:$goldReward 金币 + 5 钻石', - duration: const Duration(seconds: 3), + _showToast( + '🎊 升级 Lv.${player.value.level}!+$goldReward💰+5💎', + type: ToastType.success, ); - // 检查解锁作物的成就 _checkAchievements('unlockedCrops', player.value.unlockedCrops.length); + _checkAchievements('level', player.value.level); LoggerService().info('Player leveled up to ${player.value.level}'); } @@ -373,15 +403,18 @@ class FarmGameController extends GetxController with WidgetsBindingObserver { savePlayer(); - Get.snackbar( - '🏆 成就解锁!', - '${achievement.emoji} ${achievement.name}\n${achievement.description}', - duration: const Duration(seconds: 3), + _showToast( + '🏆 ${achievement.emoji} ${achievement.name}', + type: ToastType.success, ); LoggerService().info('Achievement unlocked: ${achievement.name}'); } } + + if (newOnes.any((a) => a.rewardExp > 0)) { + _checkLevelUp(); + } } // ==================== 解锁土地 ==================== @@ -389,13 +422,16 @@ class FarmGameController extends GetxController with WidgetsBindingObserver { Future unlockLand(int landId) async { final land = lands.firstWhereOrNull((l) => l.landId == landId); if (land == null) { - Get.snackbar('错误', '土地不存在'); + _showToast('土地不存在', type: ToastType.error); return; } if (land.isUnlocked) return; if (player.value.gold < FarmConfig.unlockLandCost) { - Get.snackbar('金币不足', '解锁土地需要 ${FarmConfig.unlockLandCost} 金币'); + _showToast( + '金币不足,需要 ${FarmConfig.unlockLandCost} 💰', + type: ToastType.warning, + ); return; } @@ -406,7 +442,7 @@ class FarmGameController extends GetxController with WidgetsBindingObserver { await _saveLand(land); lands.refresh(); - Get.snackbar('🔓 解锁成功', '土地 ${landId + 1} 已解锁'); + _showToast('🔓 土地 ${landId + 1} 已解锁', type: ToastType.success); } // ==================== 数据保存 ==================== @@ -436,7 +472,7 @@ class FarmGameController extends GetxController with WidgetsBindingObserver { Future debugAddGold() async { player.value.gold += 1000; savePlayer(); - Get.snackbar('🐛 调试', '已添加 1000 金币'); + _showToast('🐛 已添加 1000 金币', type: ToastType.info); } Future debugSpeedUp() async { @@ -449,7 +485,7 @@ class FarmGameController extends GetxController with WidgetsBindingObserver { } } lands.refresh(); - Get.snackbar('🐛 调试', '所有作物已加速成熟'); + _showToast('🐛 所有作物已加速成熟', type: ToastType.info); } Future debugUnlockAll() async { @@ -462,7 +498,7 @@ class FarmGameController extends GetxController with WidgetsBindingObserver { player.value.unlockedCrops.addAll(CropRegistry.getAll().map((c) => c.id)); savePlayer(); lands.refresh(); - Get.snackbar('🐛 调试', '已解锁所有内容'); + _showToast('🐛 已解锁所有内容', type: ToastType.info); } Future debugReset() async { @@ -471,6 +507,6 @@ class FarmGameController extends GetxController with WidgetsBindingObserver { await hiveService.farmLands?.clear(); await hiveService.farmInventory?.clear(); _loadData(); - Get.snackbar('🐛 调试', '游戏数据已重置'); + _showToast('🐛 游戏数据已重置', type: ToastType.info); } } diff --git a/lib/src/controllers/farm/farm_shop_controller.dart b/lib/src/controllers/farm/farm_shop_controller.dart index 946a8fc..6d56e99 100644 --- a/lib/src/controllers/farm/farm_shop_controller.dart +++ b/lib/src/controllers/farm/farm_shop_controller.dart @@ -7,6 +7,7 @@ import 'package:mom_kitchen/src/models/farm/inventory_item.dart'; import 'package:mom_kitchen/src/services/data/hive_service.dart'; import 'package:mom_kitchen/src/controllers/farm/farm_game_controller.dart'; import 'package:mom_kitchen/src/services/log/logger_service.dart'; +import 'package:mom_kitchen/src/services/ui/toast_service.dart'; class FarmShopController extends GetxController { final _gameController = Get.find(); @@ -30,7 +31,7 @@ class FarmShopController extends GetxController { final player = _gameController.player.value; if (player.gold < crop.seedPrice) { - Get.snackbar('金币不足', '需要 ${crop.seedPrice} 金币,当前 ${player.gold} 金币'); + ToastService.warning('金币不足,需要 ${crop.seedPrice} 💰'); return; } @@ -61,7 +62,7 @@ class FarmShopController extends GetxController { await _saveInventoryItem(seedItem); } - Get.snackbar('🛒 购买成功', '已购买 ${crop.name}种子'); + ToastService.success('🛒 已购买 ${crop.name}种子'); LoggerService().info('Bought ${crop.name} seed for ${crop.seedPrice} gold'); } diff --git a/lib/src/pages/discover/components/tools_panel_widget.dart b/lib/src/pages/discover/components/tools_panel_widget.dart index f53af64..9ade274 100644 --- a/lib/src/pages/discover/components/tools_panel_widget.dart +++ b/lib/src/pages/discover/components/tools_panel_widget.dart @@ -410,7 +410,7 @@ class _ToolsPanelWidgetState extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - '更多', + '工具中心 旧版UI', style: TextStyle( fontSize: DesignTokens.fontSm, color: DesignTokens.dynamicPrimary, diff --git a/lib/src/pages/home/recipe_detail_page.dart b/lib/src/pages/home/recipe_detail_page.dart index 8a2ab05..784a5f7 100644 --- a/lib/src/pages/home/recipe_detail_page.dart +++ b/lib/src/pages/home/recipe_detail_page.dart @@ -27,6 +27,7 @@ import 'package:mom_kitchen/src/widgets/recipe_detail/header/recipe_time_info.da import 'package:mom_kitchen/src/widgets/recipe_detail/header/recipe_title_section.dart'; import 'package:mom_kitchen/src/widgets/recipe_detail/info/recipe_similar_section.dart'; import 'package:mom_kitchen/src/widgets/recipe_detail/interaction/recipe_email_button.dart'; +import 'package:mom_kitchen/src/widgets/recipe_detail/info/recipe_taste_preference.dart'; class RecipeDetailPage extends StatelessWidget { final String recipeId; @@ -171,6 +172,7 @@ class RecipeDetailPage extends StatelessWidget { RecipeTagsSection(recipe: recipe), RecipeIngredientsSection(recipe: recipe), RecipeAllergenWarning(recipe: recipe), + RecipeTastePreference(recipe: recipe), RecipeStepsSection(recipe: recipe), RecipeNutritionSection(recipe: recipe), RecipeIngredientDetails(recipe: recipe), @@ -182,9 +184,11 @@ class RecipeDetailPage extends StatelessWidget { ? controller.rateRemaining.value : null, recipeCode: recipe.code, + recipeId: recipe.id, recipeTitle: recipe.title, categoryName: recipe.categoryName, ratingScore: recipe.rating?.score, + recipe: recipe, onRate: (score) async { await controller.rateRecipe(score: score); }, diff --git a/lib/src/pages/profile/about_page.dart b/lib/src/pages/profile/about_page.dart index a53c51c..7f6fb51 100644 --- a/lib/src/pages/profile/about_page.dart +++ b/lib/src/pages/profile/about_page.dart @@ -6,15 +6,21 @@ * 更新: 2026-04-13 新增关于页面,包含用户反馈入口 * 更新: 2026-04-13 新增开发者文档入口(API文档、App接入指南) * 更新: 2026-04-17 新增软件权限页面入口 + * 更新: 2026-04-18 了解我们入口改为跳转新页面 + * 更新: 2026-04-18 软件信息入口改为跳转AppInfoPage + * 更新: 2026-04-18 头部改用图片+长方形布局,对齐下方卡片边距 */ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart' show Divider; import 'package:get/get.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/pages/profile/app_info_page.dart'; +import 'package:mom_kitchen/src/pages/profile/learn_us_page.dart'; import 'package:mom_kitchen/src/pages/profile/privacy_policy_page.dart'; import 'package:mom_kitchen/src/pages/profile/permission_page.dart'; import 'package:mom_kitchen/src/pages/profile/references_page.dart'; +import 'package:mom_kitchen/src/services/core/app_info_service.dart'; import 'package:url_launcher/url_launcher.dart'; class AboutPage extends StatelessWidget { @@ -68,65 +74,145 @@ class AboutPage extends StatelessWidget { } Widget _buildAppHeader(bool isDark) { - return Container( - padding: const EdgeInsets.all(DesignTokens.space5), - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - borderRadius: DesignTokens.borderRadiusLg, - boxShadow: DesignTokens.shadowsSm, - ), - child: Column( - children: [ - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - DesignTokens.dynamicPrimary, - DesignTokens.dynamicPrimary.withValues(alpha: 0.7), + return GestureDetector( + onTap: () => Get.to(() => const AppInfoPage()), + behavior: HitTestBehavior.opaque, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space4, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + DesignTokens.dynamicPrimary.withValues(alpha: 0.85), + DesignTokens.dynamicPrimary, + DesignTokens.dynamicPrimary.withValues(alpha: 0.7), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: [ + BoxShadow( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.18), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: CupertinoColors.white.withValues(alpha: 0.35), + width: 2, + ), + ), + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusSm, + child: Image.asset( + 'assets/icons/icon_128x128.png', + width: 58, + height: 58, + fit: BoxFit.cover, + errorBuilder: (ctx, err, st) => Container( + width: 58, + height: 58, + decoration: BoxDecoration( + color: CupertinoColors.white.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: const Center( + child: Text('🍳', style: TextStyle(fontSize: 28)), + ), + ), + ), + ), + ), + const SizedBox(width: DesignTokens.space4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '小妈厨房', + style: TextStyle( + fontSize: DesignTokens.fontXl, + fontWeight: FontWeight.bold, + color: CupertinoColors.white, + ), + ), + const SizedBox(height: DesignTokens.space1), + Text( + '用心烹饪,用爱生活', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: CupertinoColors.white.withValues(alpha: 0.85), + ), + ), + const SizedBox(height: DesignTokens.space2), + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: CupertinoColors.white.withValues(alpha: 0.18), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.tag, + size: 12, + color: CupertinoColors.white.withValues(alpha: 0.9), + ), + const SizedBox(width: DesignTokens.space1), + Text( + 'Version ${AppInfoService().version}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w500, + color: CupertinoColors.white.withValues( + alpha: 0.95, + ), + ), + ), + ], + ), + ), ], ), - borderRadius: DesignTokens.borderRadiusLg, ), - child: const Center( - child: Text('🍳', style: TextStyle(fontSize: 40)), + Icon( + CupertinoIcons.chevron_right, + size: 16, + color: CupertinoColors.white.withValues(alpha: 0.5), ), - ), - const SizedBox(height: DesignTokens.space3), - Text( - '小妈厨房', - style: TextStyle( - fontSize: DesignTokens.fontXl, - fontWeight: FontWeight.bold, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - const SizedBox(height: DesignTokens.space1), - Text( - 'Version 0.92.4', - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - ], + ], + ), ), ); } Widget _buildAppInfoSection(bool isDark) { + final appInfo = AppInfoService(); return _buildSection( title: '📱 应用信息', isDark: isDark, children: [ - _buildInfoTile('应用版本', '0.92.4', isDark), + _buildInfoTile('应用版本', appInfo.version, isDark), _buildDivider(isDark), - _buildInfoTile('更新日期', '2026-04', isDark), + _buildInfoTile('更新日期', '2026-04-18', isDark), _buildDivider(isDark), - _buildInfoTile('构建版本', '92', isDark), + _buildInfoTile('构建版本', appInfo.buildNumber, isDark), ], ); } @@ -141,7 +227,7 @@ class AboutPage extends StatelessWidget { title: '软件信息', subtitle: '查看软件功能', isDark: isDark, - onTap: () => _openUrl('https://eat.wktyl.com/api/doc/API_DOC.md'), + onTap: () => Get.to(() => const AppInfoPage()), ), _buildDivider(isDark), _buildActionTile( @@ -149,7 +235,7 @@ class AboutPage extends StatelessWidget { title: '了解我们', subtitle: '查看关于我们', isDark: isDark, - onTap: () => _openUrl('https://eat.wktyl.com/api/doc/APP_GUIDE.md'), + onTap: () => Get.to(() => const LearnUsPage()), ), _buildDivider(isDark), _buildActionTile( diff --git a/lib/src/pages/profile/app_info_page.dart b/lib/src/pages/profile/app_info_page.dart new file mode 100644 index 0000000..4a8f76a --- /dev/null +++ b/lib/src/pages/profile/app_info_page.dart @@ -0,0 +1,1229 @@ +/* + * 文件: app_info_page.dart + * 名称: 软件信息页面 + * 作用: 展示应用版本、技术栈、构建信息、设备信息等 + * 创建: 2026-04-18 + * 更新: 2026-04-18 修复鸿蒙端设备信息显示unknown,渲染引擎动态检测 + */ + +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, TargetPlatform, kIsWeb; +import 'package:flutter/material.dart' show Divider; +import 'package:flutter/services.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/utils/platform_utils.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppInfoPage extends StatefulWidget { + const AppInfoPage({super.key}); + + @override + State createState() => _AppInfoPageState(); +} + +class _AppInfoPageState extends State { + int _tapCount = 0; + DateTime? _lastTapTime; + bool _isDeveloperMode = false; + + @override + void initState() { + super.initState(); + _loadDeveloperMode(); + } + + Future _loadDeveloperMode() async { + final prefs = await SharedPreferences.getInstance(); + if (mounted) { + setState(() { + _isDeveloperMode = prefs.getBool('developer_mode_enabled') ?? false; + }); + } + } + + Future _saveDeveloperMode(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('developer_mode_enabled', enabled); + } + + void _onFrameworkTap() { + final now = DateTime.now(); + if (_lastTapTime != null && now.difference(_lastTapTime!).inSeconds > 2) { + _tapCount = 0; + } + _lastTapTime = now; + _tapCount++; + + if (_tapCount >= 5 && !_isDeveloperMode) { + setState(() { + _isDeveloperMode = true; + }); + _saveDeveloperMode(true); + _tapCount = 0; + Get.snackbar( + '🎉 提示', + '开发者模式已激活', + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 2), + borderRadius: DesignTokens.radiusMd, + margin: const EdgeInsets.all(DesignTokens.space4), + ); + } + } + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final primaryColor = DesignTokens.dynamicPrimary; + + 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, + ), + ), + backgroundColor: isDark + ? DarkDesignTokens.glass.withValues(alpha: 0.8) + : DesignTokens.glass.withValues(alpha: 0.8), + border: null, + trailing: _isDeveloperMode + ? CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () { + Get.snackbar( + '🐛 调试', + '调试信息已激活', + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 2), + borderRadius: DesignTokens.radiusMd, + margin: const EdgeInsets.all(DesignTokens.space4), + ); + }, + child: Icon( + CupertinoIcons.ant, + color: DesignTokens.green, + size: 22, + ), + ) + : null, + ), + child: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Column( + children: [ + _buildHeaderCard(isDark, primaryColor), + const SizedBox(height: DesignTokens.space4), + _buildTechStackCard(isDark, primaryColor), + const SizedBox(height: DesignTokens.space4), + _buildBuildInfoCard(isDark, primaryColor), + const SizedBox(height: DesignTokens.space4), + _buildServerInfoCard(isDark, primaryColor), + const SizedBox(height: DesignTokens.space4), + _buildDeviceInfoCard(isDark, primaryColor), + const SizedBox(height: DesignTokens.space4), + _buildUpdateLogCard(isDark, primaryColor), + const SizedBox(height: DesignTokens.space5), + _buildBottomIndicator(isDark), + ], + ), + ), + ), + ); + } + + // 头部卡片 — 渐变 + 毛玻璃 + Widget _buildHeaderCard(bool isDark, Color primaryColor) { + return ClipRRect( + borderRadius: DesignTokens.borderRadiusLg, + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: DesignTokens.glassBlur, + sigmaY: DesignTokens.glassBlur, + ), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space5), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + primaryColor.withValues(alpha: 0.66), + primaryColor.withValues(alpha: 0.7), + primaryColor.withValues(alpha: 0.99), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: CupertinoColors.white.withValues(alpha: 0.3), + width: DesignTokens.glassBorderWidth, + ), + boxShadow: [ + BoxShadow( + color: primaryColor.withValues(alpha: 0.35), + blurRadius: DesignTokens.shadowMdBlur, + offset: const Offset(0, DesignTokens.shadowSmOffset), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 70, + height: 70, + decoration: BoxDecoration( + color: CupertinoColors.white.withValues(alpha: 0.25), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: CupertinoColors.white.withValues(alpha: 0.3), + ), + ), + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusLg, + child: Padding( + padding: const EdgeInsets.all(DesignTokens.space1), + child: Image.asset( + 'assets/icons/icon_128x128.png', + width: 62, + height: 62, + fit: BoxFit.cover, + ), + ), + ), + ), + const SizedBox(width: DesignTokens.space5), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '小妈厨房', + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.bold, + color: CupertinoColors.white, + ), + ), + const SizedBox(height: DesignTokens.space1), + Text( + "Mom's Kitchen · 软件信息", + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: CupertinoColors.white.withValues(alpha: 0.85), + ), + ), + const SizedBox(height: DesignTokens.space3), + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: CupertinoColors.white.withValues(alpha: 0.2), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: CupertinoColors.white.withValues(alpha: 0.25), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.info, + size: 14, + color: CupertinoColors.white.withValues(alpha: 0.9), + ), + const SizedBox(width: DesignTokens.space2), + GestureDetector( + onTap: _onFrameworkTap, + child: const Text( + 'Flutter', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: CupertinoColors.white, + ), + ), + ), + const SizedBox(width: DesignTokens.space2), + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: _isDeveloperMode + ? DesignTokens.green.withValues(alpha: 0.3) + : CupertinoColors.white.withValues( + alpha: 0.2, + ), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + 'v${AppConfig.appVersion}', + style: TextStyle( + fontSize: 10, + color: _isDeveloperMode + ? DesignTokens.green + : CupertinoColors.white, + fontWeight: _isDeveloperMode + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + // 技术栈卡片 + Widget _buildTechStackCard(bool isDark, Color primaryColor) { + final techItems = [ + _TechItem('Dart', '编程语言', CupertinoIcons.flame, DesignTokens.orange), + _TechItem( + 'GetX', + '状态管理', + CupertinoIcons.arrow_3_trianglepath, + DesignTokens.teal, + ), + _TechItem('Hive', '本地存储', CupertinoIcons.tray_full, DesignTokens.purple), + _TechItem('Dio', '网络请求', CupertinoIcons.wifi, DesignTokens.blue), + ]; + + return _buildCard( + title: '⚙️ 技术栈', + icon: CupertinoIcons.hammer, + isDark: isDark, + primaryColor: primaryColor, + child: Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + mainAxisSpacing: DesignTokens.space3, + crossAxisSpacing: DesignTokens.space3, + childAspectRatio: 2.2, + children: techItems + .map((item) => _buildTechStackItem(item, isDark)) + .toList(), + ), + ), + ); + } + + Widget _buildTechStackItem(_TechItem item, bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: item.color.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all(color: item.color.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(DesignTokens.space2), + decoration: BoxDecoration( + color: item.color.withValues(alpha: 0.2), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Icon(item.icon, color: item.color, size: 20), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + item.title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + item.subtitle, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ); + } + + // 构建信息卡片 + Widget _buildBuildInfoCard(bool isDark, Color primaryColor) { + String buildSdk = 'Unknown'; + try { + if (PlatformUtils().isHarmonyOS) { + buildSdk = 'Deveco API 23'; + } else if (PlatformUtils().isAndroid) { + buildSdk = 'Android Target 36'; + } else if (PlatformUtils().isWindows) { + buildSdk = 'Win10 SDK'; + } else if (PlatformUtils().isIOS) { + buildSdk = 'iOS 26'; + } else if (PlatformUtils().isMacOS) { + buildSdk = 'macOS 18'; + } else if (PlatformUtils().isLinux) { + buildSdk = 'Linux 20'; + } else { + buildSdk = 'PHP 7.4'; + } + } catch (_) { + buildSdk = 'PHP 7.4'; + } + + return _buildCard( + title: '🔨 构建信息', + icon: CupertinoIcons.wrench, + isDark: isDark, + primaryColor: primaryColor, + child: Column( + children: [ + _buildCopyableItem( + '版本号', + AppConfig.appVersion, + CupertinoIcons.star, + isDark, + primaryColor, + ), + _buildDivider(isDark), + _buildCopyableItem( + '内部版本号', + AppConfig.buildNumber, + CupertinoIcons.number, + isDark, + primaryColor, + ), + _buildDivider(isDark), + _buildInfoItem( + '打包时间', + DateTime.now().toString().split(' ').first, + CupertinoIcons.calendar, + isDark, + ), + _buildDivider(isDark), + _buildInfoItem( + 'Build SDK', + buildSdk, + CupertinoIcons.cube_box, + isDark, + ), + _buildDivider(isDark), + _buildLicenseItem(isDark, primaryColor), + ], + ), + ); + } + + Widget _buildLicenseItem(bool isDark, Color primaryColor) { + return GestureDetector( + onTap: () => _showLicenseDialog(isDark, primaryColor), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + child: Row( + children: [ + Icon(CupertinoIcons.doc_plaintext, size: 20, color: primaryColor), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '开源框架', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const SizedBox(height: 2), + Text( + 'Flutter SDK', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + Icon( + CupertinoIcons.chevron_right, + size: 16, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ], + ), + ), + ); + } + + void _showLicenseDialog(bool isDark, Color primaryColor) { + final licenses = [ + {'name': 'Flutter SDK', 'license': 'BSD 3-Clause'}, + {'name': 'OpenHarmony SDK', 'license': 'Apache 2.0'}, + {'name': 'Cupertino Icons', 'license': 'MIT'}, + {'name': 'Shared Preferences', 'license': 'BSD 3-Clause'}, + {'name': 'Dio', 'license': 'MIT'}, + {'name': 'GetX', 'license': 'MIT'}, + {'name': 'Hive CE', 'license': 'Apache 2.0'}, + {'name': 'url_launcher', 'license': 'BSD 3-Clause'}, + {'name': 'package_info_plus', 'license': 'BSD 3-Clause'}, + {'name': 'device_info_plus', 'license': 'BSD 3-Clause'}, + ]; + + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: Row( + children: [ + Icon(CupertinoIcons.doc_plaintext, color: primaryColor), + const SizedBox(width: DesignTokens.space2), + const Text('开源框架'), + ], + ), + content: Padding( + padding: const EdgeInsets.only(top: DesignTokens.space3), + child: SizedBox( + width: double.maxFinite, + height: 280, + child: ListView( + shrinkWrap: true, + children: licenses.map((item) { + return Container( + margin: const EdgeInsets.only(bottom: DesignTokens.space2), + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.card + : DesignTokens.background, + borderRadius: DesignTokens.borderRadiusSm, + border: Border.all( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.15) + : DesignTokens.text3.withValues(alpha: 0.15), + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(DesignTokens.space1), + decoration: BoxDecoration( + color: primaryColor.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Icon( + CupertinoIcons.cube_box, + size: 16, + color: primaryColor, + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item['name']!, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const SizedBox(height: 2), + Text( + item['license']!, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('关闭'), + ), + ], + ), + ); + } + + // 后端服务卡片 + Widget _buildServerInfoCard(bool isDark, Color primaryColor) { + return _buildCard( + title: '🖥️ 后端服务', + icon: CupertinoIcons.desktopcomputer, + isDark: isDark, + primaryColor: primaryColor, + child: Column( + children: [ + _buildInfoItem('后端语言', 'PHP', CupertinoIcons.paperplane, isDark), + _buildDivider(isDark), + _buildInfoItem('Web 服务器', 'Nginx', CupertinoIcons.globe, isDark), + _buildDivider(isDark), + _buildCopyableItem( + 'API 地址', + AppConfig.baseUrl, + CupertinoIcons.link, + isDark, + primaryColor, + ), + ], + ), + ); + } + + // 设备信息卡片 + Widget _buildDeviceInfoCard(bool isDark, Color primaryColor) { + final platform = PlatformUtils(); + String platformName = platform.operatingSystemName; + String deviceType = '未知设备'; + + if (platform.isMobile) { + deviceType = '📱 移动设备'; + } else if (platform.isDesktop) { + deviceType = '💻 桌面设备'; + } else if (platform.isWeb) { + deviceType = '🌐 Web 浏览器'; + } + + final size = MediaQuery.of(context).size; + final pixelRatio = MediaQuery.of(context).devicePixelRatio; + + return _buildCard( + title: '📱 设备信息', + icon: CupertinoIcons.device_phone_portrait, + isDark: isDark, + primaryColor: primaryColor, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + DesignTokens.space4, + DesignTokens.space3, + DesignTokens.space4, + 0, + ), + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 2, + mainAxisSpacing: DesignTokens.space3, + crossAxisSpacing: DesignTokens.space3, + childAspectRatio: 2.0, + children: [ + _buildGridInfoItem( + '操作系统', + platformName, + CupertinoIcons.device_desktop, + isDark, + primaryColor, + ), + _buildGridInfoItem( + '设备类型', + deviceType, + CupertinoIcons.device_phone_portrait, + isDark, + primaryColor, + ), + + _buildGridInfoItem( + 'Dart 版本', + _getDartVersion(), + CupertinoIcons.bolt, + isDark, + primaryColor, + ), + _buildGridInfoItem( + '渲染引擎', + _getRenderingEngine(), + CupertinoIcons.paintbrush, + isDark, + primaryColor, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.08) + : DesignTokens.text3.withValues(alpha: 0.06), + borderRadius: DesignTokens.borderRadiusSm, + border: Border.all( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.1) + : DesignTokens.text3.withValues(alpha: 0.1), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + CupertinoIcons.info_circle, + size: 16, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + const SizedBox(width: DesignTokens.space2), + Text( + '屏幕详细信息', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + Text( + '窗口尺寸: ${size.width.toStringAsFixed(0)} × ${size.height.toStringAsFixed(0)}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + const SizedBox(height: DesignTokens.space1), + Text( + '像素密度: ${pixelRatio.toStringAsFixed(2)} (越大越清晰)', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + // 更新日志卡片 + Widget _buildUpdateLogCard(bool isDark, Color primaryColor) { + return _buildCard( + title: '📋 更新日志', + icon: CupertinoIcons.doc_text, + isDark: isDark, + primaryColor: primaryColor, + child: Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Column( + children: [ + _buildUpdateItem( + '版本 ${AppConfig.appVersion}', + '2026-04', + ['新增软件信息页面、技术栈展示', '优化关于页面布局与交互体验'], + isDark, + primaryColor, + ), + const SizedBox(height: DesignTokens.space3), + _buildUpdateItem( + '版本 0.92.4', + '2026-04', + ['新增关于页面、权限页面、参考文献页面', '优化主题系统与毛玻璃效果'], + isDark, + primaryColor, + ), + ], + ), + ), + ); + } + + Widget _buildUpdateItem( + String version, + String date, + List changes, + bool isDark, + Color primaryColor, + ) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.08) + : DesignTokens.text3.withValues(alpha: 0.06), + borderRadius: DesignTokens.borderRadiusSm, + border: Border.all( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.1) + : DesignTokens.text3.withValues(alpha: 0.1), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + version, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.bold, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + Text( + date, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: changes.map((change) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(width: DesignTokens.space2), + Text('• ', style: TextStyle(color: primaryColor)), + Expanded( + child: Text( + change, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ), + ], + ), + ); + }).toList(), + ), + ], + ), + ); + } + + // 底部标识 + Widget _buildBottomIndicator(bool isDark) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + children: [ + Icon( + CupertinoIcons.heart, + size: 24, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + const SizedBox(height: DesignTokens.space2), + Text( + '用心烹饪,用爱生活', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + const SizedBox(height: DesignTokens.space1), + Text( + '© 2026 ${AppConfig.appName}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ); + } + + // ========== 通用组件 ========== + + Widget _buildCard({ + required String title, + required IconData icon, + required bool isDark, + required Color primaryColor, + required Widget child, + }) { + return Container( + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(DesignTokens.space2), + decoration: BoxDecoration( + color: primaryColor.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Icon(icon, color: primaryColor, size: 20), + ), + const SizedBox(width: DesignTokens.space3), + Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.bold, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + ), + Divider( + height: 1, + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.1) + : DesignTokens.text3.withValues(alpha: 0.1), + ), + child, + ], + ), + ); + } + + Widget _buildInfoItem( + String title, + String value, + IconData icon, + bool isDark, + ) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + child: Row( + children: [ + Icon( + icon, + size: 20, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: 2), + Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildCopyableItem( + String title, + String value, + IconData icon, + bool isDark, + Color primaryColor, + ) { + return GestureDetector( + onTap: () => _copyToClipboard(value), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + child: Row( + children: [ + Icon( + icon, + size: 20, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const SizedBox(height: 2), + Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + Icon( + CupertinoIcons.doc_on_doc, + size: 16, + color: primaryColor.withValues(alpha: 0.6), + ), + ], + ), + ), + ); + } + + Widget _buildGridInfoItem( + String title, + String value, + IconData icon, + bool isDark, + Color primaryColor, + ) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.08) + : DesignTokens.text3.withValues(alpha: 0.06), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.1) + : DesignTokens.text3.withValues(alpha: 0.1), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: primaryColor), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space1), + Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ], + ), + ); + } + + Widget _buildDivider(bool isDark) { + return Padding( + padding: const EdgeInsets.only( + left: DesignTokens.space4 + 20 + DesignTokens.space3, + ), + child: Divider( + height: 1, + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.1) + : DesignTokens.text3.withValues(alpha: 0.1), + ), + ); + } + + String _getDartVersion() { + try { + final fullVersion = PlatformUtils().dartVersion; + final match = RegExp(r'^(\d+\.\d+\.\d+)').firstMatch(fullVersion); + if (match != null) { + return '${match.group(1)} (VM)'; + } + return fullVersion; + } catch (_) { + return 'Unknown'; + } + } + + String _getRenderingEngine() { + try { + final dynamic dispatcher = PlatformDispatcher.instance; + final bool? enabled = dispatcher.impellerEnabled as bool?; + if (enabled == true) return 'Impeller (API)'; + if (enabled == false) return 'Skia (API)'; + } catch (_) {} + + try { + final dynamic view = PlatformDispatcher.instance.views.first; + final dynamic engine = view.renderingEngine; + if (engine != null) { + final String name = engine.name.toString(); + switch (name) { + case 'skia': + return 'Skia (引擎)'; + case 'impeller': + return 'Impeller (引擎)'; + case 'canvasKit': + return 'CanvasKit (引擎)'; + case 'html': + return 'HTML (引擎)'; + default: + return '$name (引擎)'; + } + } + } catch (_) {} + + if (kIsWeb) return 'CanvasKit/HTML (平台)'; + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + return 'Impeller (平台)'; + case TargetPlatform.android: + return 'Impeller (平台)'; + case TargetPlatform.ohos: + return 'Impeller (平台)'; + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return 'Skia (平台)'; + } + } + + void _copyToClipboard(String text) { + Clipboard.setData(ClipboardData(text: text)); + Get.snackbar( + '✅ 复制成功', + '$text 已复制到剪贴板', + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 2), + borderRadius: DesignTokens.radiusMd, + margin: const EdgeInsets.all(DesignTokens.space4), + ); + } +} + +class _TechItem { + final String title; + final String subtitle; + final IconData icon; + final Color color; + + const _TechItem(this.title, this.subtitle, this.icon, this.color); +} diff --git a/lib/src/pages/profile/data_export_page.dart b/lib/src/pages/profile/data_export_page.dart index 2ca72c3..01adcd6 100644 --- a/lib/src/pages/profile/data_export_page.dart +++ b/lib/src/pages/profile/data_export_page.dart @@ -422,7 +422,7 @@ class _DataExportPageState extends State { title: const Text('📥 分享导入说明'), content: const Text( '1. 在文件管理器或其他应用中找到导出的 JSON 文件\n' - '2. 点击分享按钮,选择"妈妈厨房"\n' + '2. 点击分享按钮,选择"小妈厨房"\n' '3. 应用会自动识别并预览导入数据\n' '4. 确认后即可完成导入', ), diff --git a/lib/src/pages/profile/learn_us_page.dart b/lib/src/pages/profile/learn_us_page.dart new file mode 100644 index 0000000..0db5f1d --- /dev/null +++ b/lib/src/pages/profile/learn_us_page.dart @@ -0,0 +1,904 @@ +/* + * 文件: learn_us_page.dart + * 名称: 了解我们页面 + * 作用: 展示开发者信息、团队信息、官网和备案号 + * 创建: 2026-04-18 + * 更新: 2026-04-18 新增了解我们页面 + * 更新: 2026-04-18 头部emoji改用图片,版本号同步更新 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart' show Divider; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/services/core/app_info_service.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class LearnUsPage extends StatelessWidget { + const LearnUsPage({super.key}); + + @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, + ), + ), + backgroundColor: isDark + ? DarkDesignTokens.glass.withValues(alpha: 0.8) + : DesignTokens.glass.withValues(alpha: 0.8), + border: null, + ), + child: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Column( + children: [ + _buildHeaderCard(isDark), + const SizedBox(height: DesignTokens.space4), + _buildOfficialSiteSection(isDark), + const SizedBox(height: DesignTokens.space4), + _buildDeveloperSection(isDark), + const SizedBox(height: DesignTokens.space4), + _buildTeamSection(isDark), + const SizedBox(height: DesignTokens.space4), + _buildIcpSection(isDark), + const SizedBox(height: DesignTokens.space6), + _buildBottomIndicator(isDark), + ], + ), + ), + ), + ); + } + + /// 顶部头部卡片 + Widget _buildHeaderCard(bool isDark) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space4, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + DesignTokens.dynamicPrimary.withValues(alpha: 0.85), + DesignTokens.dynamicPrimary, + DesignTokens.dynamicPrimary.withValues(alpha: 0.65), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: [ + BoxShadow( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.18), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: CupertinoColors.white.withValues(alpha: 0.35), + width: 2, + ), + ), + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusSm, + child: Image.asset( + 'assets/icons/icon_128x128.png', + width: 58, + height: 58, + fit: BoxFit.cover, + errorBuilder: (ctx, err, st) => Container( + width: 58, + height: 58, + decoration: BoxDecoration( + color: CupertinoColors.white.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: const Center( + child: Text('🍳', style: TextStyle(fontSize: 28)), + ), + ), + ), + ), + ), + const SizedBox(width: DesignTokens.space5), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '小妈厨房', + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.bold, + color: CupertinoColors.white, + ), + ), + const SizedBox(height: DesignTokens.space1), + Text( + '用心烹饪,用爱生活', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: CupertinoColors.white.withValues(alpha: 0.85), + ), + ), + const SizedBox(height: DesignTokens.space3), + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: CupertinoColors.white.withValues(alpha: 0.2), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.tag, + size: 14, + color: CupertinoColors.white.withValues(alpha: 0.9), + ), + const SizedBox(width: DesignTokens.space2), + Text( + 'Version ${AppInfoService().version}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: CupertinoColors.white.withValues(alpha: 0.95), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// 官方网站区域 + Widget _buildOfficialSiteSection(bool isDark) { + return _buildSection( + title: '🌐 官方网站', + isDark: isDark, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + DesignTokens.space4, + 0, + DesignTokens.space4, + DesignTokens.space3, + ), + child: Text( + '访问我们的官方网站了解更多信息', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ), + _buildLinkTile( + icon: CupertinoIcons.globe, + label: 'API 服务', + url: 'https://eat.wktyl.com/api/', + isDark: isDark, + ), + _buildDivider(isDark), + _buildLinkTile( + icon: CupertinoIcons.doc_text, + label: 'App 接入指南', + url: 'https://eat.wktyl.com/api/doc/APP_GUIDE.md', + isDark: isDark, + ), + _buildDivider(isDark), + _buildLinkTile( + icon: CupertinoIcons.book, + label: 'API 文档', + url: 'https://eat.wktyl.com/api/doc/API_DOC.md', + isDark: isDark, + ), + ], + ); + } + + /// 开发者区域 + Widget _buildDeveloperSection(bool isDark) { + return _buildSection( + title: '🏢 开发者', + isDark: isDark, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + child: Row( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + DesignTokens.dynamicPrimaryLight, + DesignTokens.dynamicPrimary.withValues(alpha: 0.05), + ], + ), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Icon( + CupertinoIcons.building_2_fill, + size: 26, + color: DesignTokens.dynamicPrimary, + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '微风暴网络科技工作室', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.bold, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const SizedBox(height: 2), + Text( + '专注美食与生活领域', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + ], + ), + ), + _buildDivider(isDark), + _buildCopyTile( + icon: CupertinoIcons.mail, + title: '商务合作 & 联系我们', + value: 'support@momkitchen.app', + isDark: isDark, + ), + _buildDivider(isDark), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: const Color(0xFF07C160).withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Icon( + CupertinoIcons.chat_bubble_2_fill, + size: 22, + color: const Color(0xFF07C160), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '微信公众号', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const SizedBox(height: 4), + GestureDetector( + onTap: () { + Clipboard.setData(const ClipboardData(text: '微风暴')); + Get.snackbar( + '复制成功', + '已复制到剪贴板', + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 2), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: const Color(0xFF07C160).withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + CupertinoIcons.search, + size: 14, + color: Color(0xFF07C160), + ), + const SizedBox(width: DesignTokens.space2), + const Text( + '微风暴', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: Color(0xFF07C160), + ), + ), + const SizedBox(width: DesignTokens.space2), + Icon( + CupertinoIcons.doc_on_clipboard, + size: 14, + color: const Color( + 0xFF07C160, + ).withValues(alpha: 0.8), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ); + } + + /// 团队区域 + Widget _buildTeamSection(bool isDark) { + return _buildSection( + title: '👥 团队信息', + isDark: isDark, + children: [ + _buildTeamMember('💻', '程序设计', '纯情小妈', '喜欢发呆', isDark), + _buildDivider(isDark), + _buildTeamMember('🎨', 'UI/UX/Testing', 'Freetime', '关于你的风景。', isDark), + _buildDivider(isDark), + _buildTeamMember('⚙️', '后端开发', '伯乐不相马', '还是做不到吗?', isDark), + _buildDivider(isDark), + _buildTeamMember('🔧', '技术支持', 'Ayk', '吾友随贱,其寿似龟', isDark), + ], + ); + } + + /// ICP备案区域 + Widget _buildIcpSection(bool isDark) { + return _buildSection( + title: '📋 ICP备案信息', + isDark: isDark, + children: [ + GestureDetector( + onTap: () { + Clipboard.setData( + const ClipboardData(text: '滇ICP备2022000863号-15A'), + ); + Get.snackbar( + '复制成功', + '备案号已复制到剪贴板', + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 1), + ); + }, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '滇ICP备2022000863号-15A', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + decoration: TextDecoration.underline, + decorationColor: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + const SizedBox(height: 2), + Text( + 'APP核准备案号', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + Icon( + CupertinoIcons.doc_on_clipboard, + size: 18, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ], + ), + ), + ), + ], + ); + } + + /// 通用区域容器 + Widget _buildSection({ + required String title, + required bool isDark, + required List children, + }) { + return Container( + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + DesignTokens.space4, + DesignTokens.space3, + DesignTokens.space4, + DesignTokens.space1, + ), + child: Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ), + ...children, + ], + ), + ); + } + + /// 链接项 + Widget _buildLinkTile({ + required IconData icon, + required String label, + required String url, + required bool isDark, + }) { + return GestureDetector( + onTap: () => _showLaunchDialog(url, label, isDark), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.12), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Icon(icon, size: 20, color: DesignTokens.dynamicPrimary), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const SizedBox(height: 2), + Text( + url, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: url)); + Get.snackbar( + '复制成功', + '链接已复制到剪贴板', + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 2), + ); + }, + child: Container( + padding: const EdgeInsets.all(DesignTokens.space1), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Icon( + CupertinoIcons.doc_on_clipboard, + size: 16, + color: DesignTokens.dynamicPrimary, + ), + ), + ), + const SizedBox(width: DesignTokens.space2), + Icon( + CupertinoIcons.arrow_up_right_square, + size: 18, + color: DesignTokens.dynamicPrimary, + ), + ], + ), + ], + ), + ), + ); + } + + /// 可复制的文本项 + Widget _buildCopyTile({ + required IconData icon, + required String title, + required String value, + required bool isDark, + }) { + return GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: value)); + Get.snackbar( + '复制成功', + '已复制到剪贴板', + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 2), + ); + }, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: DesignTokens.blue.withValues(alpha: 0.12), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Icon(icon, size: 20, color: DesignTokens.blue), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const SizedBox(height: 2), + Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: DesignTokens.blue, + ), + ), + ], + ), + ), + Icon( + CupertinoIcons.doc_on_clipboard, + size: 18, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ], + ), + ), + ); + } + + /// 团队成员项 + Widget _buildTeamMember( + String emoji, + String role, + String name, + String signature, + bool isDark, + ) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.card.withValues(alpha: 0.5) + : DesignTokens.background, + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Center( + child: Text(emoji, style: const TextStyle(fontSize: 20)), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + role, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const SizedBox(width: DesignTokens.space2), + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues( + alpha: 0.1, + ), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: Text( + name, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + signature, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// 分割线 + Widget _buildDivider(bool isDark) { + return Padding( + padding: const EdgeInsets.only( + left: DesignTokens.space4 + 36 + DesignTokens.space3, + ), + child: Divider( + height: 1, + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.1) + : DesignTokens.text3.withValues(alpha: 0.1), + ), + ); + } + + /// 底部指示器 + Widget _buildBottomIndicator(bool isDark) { + return Container( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space5), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 40, + height: 1, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + child: Text( + '到底了', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ), + Container( + width: 40, + height: 1, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ], + ), + ); + } + + /// 打开链接确认弹窗 + void _showLaunchDialog(String url, String label, bool isDark) { + Get.dialog( + CupertinoAlertDialog( + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.arrow_up_right_square, + color: DesignTokens.dynamicPrimary, + size: 20, + ), + const SizedBox(width: DesignTokens.space2), + const Text('打开链接'), + ], + ), + content: Column( + children: [ + const SizedBox(height: DesignTokens.space3), + Text( + '即将离开应用,在浏览器中打开:', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + height: 1.5, + ), + ), + const SizedBox(height: DesignTokens.space2), + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: DesignTokens.dynamicPrimary, + ), + ), + const SizedBox(height: DesignTokens.space1), + Text( + url, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + actions: [ + CupertinoDialogAction( + onPressed: () { + Clipboard.setData(ClipboardData(text: url)); + Get.back(); + Get.snackbar( + '复制成功', + '链接已复制到剪贴板', + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 2), + ); + }, + child: const Text('复制链接'), + ), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () => Get.back(), + child: const Text('取消'), + ), + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () { + Get.back(); + _launchUrl(url); + }, + child: const Text('前往'), + ), + ], + ), + ); + } + + /// 启动URL + Future _launchUrl(String url) async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + Get.snackbar( + '打开失败', + '无法打开链接,请手动复制后在浏览器中访问', + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 2), + ); + } + } +} diff --git a/lib/src/pages/profile/profile_home.dart b/lib/src/pages/profile/profile_home.dart index 0b53082..f30b2b9 100644 --- a/lib/src/pages/profile/profile_home.dart +++ b/lib/src/pages/profile/profile_home.dart @@ -280,13 +280,13 @@ class ProfileHomeTab extends StatelessWidget { ), _FeatureItem( CupertinoIcons.cart, - '购物清单', + '分享记录', DesignTokens.green, AppRoutes.shoppingList, ), _FeatureItem( CupertinoIcons.bookmark, - '收藏', + '评分记录', DesignTokens.secondary, AppRoutes.favorites, ), diff --git a/lib/src/pages/profile/references_page.dart b/lib/src/pages/profile/references_page.dart index 0306dc6..069bdeb 100644 --- a/lib/src/pages/profile/references_page.dart +++ b/lib/src/pages/profile/references_page.dart @@ -3,7 +3,7 @@ * 名称: 参考文献页面 * 作用: 展示健康饮食相关的权威参考文献链接 * 创建时间: 2026-04-13 - * 更新时间: 2026-04-13 + * 更新时间: 2026-04-18 根据软件功能增加参考文献,替换无法访问的链接 */ import 'package:flutter/cupertino.dart'; @@ -35,58 +35,135 @@ class ReferencesPage extends StatelessWidget { ReferenceItem( title: '减盐', description: '世界卫生组织关于减少钠摄入的事实说明,介绍钠摄入过量的健康风险及减盐策略。', - url: 'https://www.who.int/zh/news-room/fact-sheets/detail/sodium-reduction', + url: + 'https://www.who.int/zh/news-room/fact-sheets/detail/sodium-reduction', source: '世界卫生组织 WHO', - category: '营养健康', + category: '🥗 营养健康', ), ReferenceItem( title: '健康饮食', description: '世界卫生组织健康饮食指南,涵盖均衡膳食、营养素摄入建议及饮食原则。', url: 'https://www.who.int/zh/news-room/fact-sheets/detail/healthy-diet', source: '世界卫生组织 WHO', - category: '营养健康', + category: '🥗 营养健康', ), ReferenceItem( title: '成人和儿童糖摄入量指南', description: 'WHO关于成人和儿童糖摄入量的官方指南,建议游离糖摄入量控制在总能量的10%以下。', url: 'https://www.who.int/publications/i/item/9789241549028', source: '世界卫生组织 WHO', - category: '营养健康', + category: '🥗 营养健康', + ), + ReferenceItem( + title: '微量营养素缺乏', + description: 'WHO关于维生素和矿物质缺乏的信息,介绍碘、维生素A、铁等微量营养素缺乏的影响。', + url: + 'https://www.who.int/zh/news-room/fact-sheets/detail/micronutrient-deficiencies', + source: '世界卫生组织 WHO', + category: '🥗 营养健康', ), ReferenceItem( title: '身体活动和久坐行为指南', description: 'WHO关于身体活动的建议,介绍不同年龄段人群的运动量推荐及久坐的健康风险。', url: 'https://www.who.int/publications/i/item/9789240015128', source: '世界卫生组织 WHO', - category: '生活方式', - ), - ReferenceItem( - title: '中国居民膳食指南(2022)', - description: '中国营养学会发布的官方膳食指南,针对中国居民的饮食特点和营养需求提供建议。', - url: 'https://www.cnsoc.org/', - source: '中国营养学会', - category: '膳食指南', + category: '🏃 生活方式', ), ReferenceItem( title: '食品安全五大要点', description: 'WHO发布的食品安全基本准则,包括保持清洁、生熟分开、烧熟煮透等关键要点。', url: 'https://www.who.int/zh/news-room/fact-sheets/detail/food-safety', source: '世界卫生组织 WHO', - category: '食品安全', + category: '🛡️ 食品安全', ), ReferenceItem( - title: '微量营养素缺乏', - description: 'WHO关于维生素和矿物质缺乏的信息,介绍碘、维生素A、铁等微量营养素缺乏的影响。', - url: 'https://www.who.int/zh/news-room/fact-sheets/detail/micronutrient-deficiencies', - source: '世界卫生组织 WHO', - category: '营养健康', + title: '中国居民膳食指南(2022)', + description: '中国营养学会发布的官方膳食指南,针对中国居民的饮食特点和营养需求提供建议。', + url: 'https://www.cnsoc.org/', + source: '中国营养学会', + category: '📋 膳食指南', ), ReferenceItem( title: '中国食物成分表', description: '中国疾病预防控制中心营养与健康所发布的食物营养成分数据库,提供权威的食物营养数据。', url: 'https://cdc.chinacdc.cn/', source: '中国疾控中心', - category: '数据来源', + category: '📊 数据来源', + ), + ReferenceItem( + title: 'BMI分类标准', + description: 'WHO关于成人体重指数(BMI)的分类标准,用于评估体重是否在健康范围内。', + url: + 'https://www.who.int/zh/news-room/fact-sheets/detail/obesity-and-overweight', + source: '世界卫生组织 WHO', + category: '⚖️ 体重管理', + ), + ReferenceItem( + title: '食物过敏与不耐受', + description: '世界过敏组织关于食物过敏的指南,介绍常见过敏原、症状识别及应对措施。', + url: + 'https://www.worldallergy.org/education-and-programs/education/allergic-disease-resource-center/professional/food-allergy', + source: '世界过敏组织 WAO', + category: '⚠️ 过敏原', + ), + ReferenceItem( + title: '中国食物过敏指南', + description: '中华医学会发布的食物过敏相关诊疗指南,涵盖常见致敏食物及临床管理建议。', + url: 'https://www.cma.org.cn/', + source: '中华医学会', + category: '⚠️ 过敏原', + ), + ReferenceItem( + title: '孕期和哺乳期膳食指南', + description: '中国营养学会针对孕产妇的营养建议,包括叶酸、铁、钙等关键营养素的补充指导。', + url: 'https://www.cnsoc.org/', + source: '中国营养学会', + category: '🤰 特殊人群', + ), + ReferenceItem( + title: '婴幼儿喂养指南', + description: 'WHO关于婴幼儿辅食添加和喂养的全球策略,推荐纯母乳喂养6个月后逐步添加辅食。', + url: + 'https://www.who.int/zh/news-room/fact-sheets/detail/infant-and-young-child-feeding', + source: '世界卫生组织 WHO', + category: '👶 特殊人群', + ), + ReferenceItem( + title: '老年人营养指南', + description: 'WHO关于老年人营养的建议,关注蛋白质、维生素D、钙等营养素的充足摄入。', + url: + 'https://www.who.int/zh/news-room/fact-sheets/detail/ageing-and-health', + source: '世界卫生组织 WHO', + category: '👴 特殊人群', + ), + ReferenceItem( + title: '烹饪温度与食品安全', + description: '美国FDA关于安全烹饪温度的指南,不同食材需达到的最低内部温度以杀灭有害微生物。', + url: + 'https://www.fda.gov/food/people-risk-foodborne-illness/meat-poultry-seafood-food-safety-moms-be', + source: '美国FDA', + category: '🍳 烹饪安全', + ), + ReferenceItem( + title: '中国居民膳食营养素参考摄入量', + description: '中国营养学会发布的DRIs,涵盖能量、蛋白质、维生素、矿物质等各类营养素的推荐摄入量。', + url: 'https://www.cnsoc.org/', + source: '中国营养学会', + category: '📊 数据来源', + ), + ReferenceItem( + title: '食物升糖指数(GI)数据库', + description: '悉尼大学国际GI数据库,提供各类食物的血糖生成指数,辅助糖尿病患者和健康人群选择食物。', + url: 'https://glycemicindex.com/', + source: '悉尼大学', + category: '📊 数据来源', + ), + ReferenceItem( + title: '中国慢性病防控指南', + description: '中国疾控中心关于高血压、糖尿病等慢性病的膳食防控建议,强调减盐、控油、限糖。', + url: 'https://www.chinacdc.cn/', + source: '中国疾控中心', + category: '🏥 慢病防控', ), ]; @@ -95,7 +172,9 @@ class ReferencesPage extends StatelessWidget { final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; return CupertinoPageScaffold( - backgroundColor: isDark ? DarkDesignTokens.background : DesignTokens.background, + backgroundColor: isDark + ? DarkDesignTokens.background + : DesignTokens.background, navigationBar: CupertinoNavigationBar( middle: Text( '参考文献', diff --git a/lib/src/pages/profile/settings/preference_page.dart b/lib/src/pages/profile/settings/preference_page.dart index e1117e6..6cba110 100644 --- a/lib/src/pages/profile/settings/preference_page.dart +++ b/lib/src/pages/profile/settings/preference_page.dart @@ -1,62 +1,91 @@ /* * 文件: preference_page.dart - * 说明: 用户偏好设置页面。管理口味偏好分类、标签和过敏原屏蔽。 - * 作用: iOS风格设置页面,支持分类/标签/过敏原的开关切换。 - * 作者: 前端工程师 - * 更新时间: 2026-04-09 - * 上次更新: 新建偏好设置页面 + * 名称: 偏好设置页面 + * 作用: 管理用户口味偏好、饮食类型、烹饪水平、健康目标、过敏原屏蔽等 + * 创建时间: 2026-04-09 + * 更新时间: 2026-04-18 接入TastePreferenceService持久化,偏好值写入SharedPreferences */ import 'package:flutter/cupertino.dart'; import 'package:get/get.dart'; import 'package:mom_kitchen/src/controllers/user/preference_controller.dart'; import 'package:mom_kitchen/src/models/user/user_preference_model.dart'; -import 'package:mom_kitchen/src/services/ui/theme_service.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/services/user/taste_preference_service.dart'; -class PreferencePage extends StatelessWidget { +class PreferencePage extends StatefulWidget { const PreferencePage({super.key}); + @override + State createState() => _PreferencePageState(); +} + +class _PreferencePageState extends State { + final PreferenceController _controller = Get.find(); + final TastePreferenceService _tasteService = Get.find(); + @override Widget build(BuildContext context) { - final themeService = Get.find(); - final prefController = Get.find(); + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; return CupertinoPageScaffold( + backgroundColor: isDark + ? DarkDesignTokens.background + : DesignTokens.background, navigationBar: CupertinoNavigationBar( - middle: Text('口味偏好 🍽️'), + middle: Text( + '口味偏好 🍽️', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + backgroundColor: isDark + ? DarkDesignTokens.glass.withValues(alpha: 0.8) + : DesignTokens.glass.withValues(alpha: 0.8), + border: null, trailing: CupertinoButton( padding: EdgeInsets.zero, - onPressed: () => _showClearConfirm(context, prefController), + onPressed: () => _showClearConfirm(context, isDark), child: Text( '重置', style: TextStyle( - fontSize: 14, - color: themeService.primaryColor.value, + fontSize: DesignTokens.fontSm, + color: DesignTokens.dynamicPrimary, ), ), ), ), child: SafeArea( child: Obx(() { - if (prefController.isLoading.value && - prefController.preference.value == null) { + if (_controller.isLoading.value && + _controller.preference.value == null) { return const Center(child: CupertinoActivityIndicator(radius: 20)); } return SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 20), + padding: const EdgeInsets.all(DesignTokens.space4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionHeader('📂 偏好分类', '选择你喜欢的菜系分类', themeService), - _buildCategorySection(prefController, themeService), - const SizedBox(height: 24), - _buildSectionHeader('🏷️ 偏好标签', '选择你感兴趣的标签', themeService), - _buildTagSection(prefController, themeService), - const SizedBox(height: 24), - _buildSectionHeader('⚠️ 过敏原屏蔽', '屏蔽含这些食材的菜谱', themeService), - _buildAllergenSection(prefController, themeService), - const SizedBox(height: 40), + _buildDietTypeCard(isDark), + const SizedBox(height: DesignTokens.space4), + _buildSpiceCard(isDark), + const SizedBox(height: DesignTokens.space4), + _buildTasteCard(isDark), + const SizedBox(height: DesignTokens.space4), + _buildCookingLevelCard(isDark), + const SizedBox(height: DesignTokens.space4), + _buildServingSizeCard(isDark), + const SizedBox(height: DesignTokens.space4), + _buildHealthGoalCard(isDark), + const SizedBox(height: DesignTokens.space4), + _buildCategoryCard(isDark), + const SizedBox(height: DesignTokens.space4), + _buildAllergenCard(isDark), + const SizedBox(height: DesignTokens.space5), + _buildSummaryCard(isDark), + const SizedBox(height: DesignTokens.space5), ], ), ); @@ -65,198 +94,478 @@ class PreferencePage extends StatelessWidget { ); } - Widget _buildSectionHeader( - String title, - String subtitle, - ThemeService themeService, - ) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + Widget _buildCard({ + required bool isDark, + required String title, + required String subtitle, + required String emoji, + required Widget child, + }) { + 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: [ - Text( - title, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: themeService.textColor.value, - ), - ), - const SizedBox(height: 4), - Text( - subtitle, - style: TextStyle( - fontSize: 13, - color: themeService.textColor.value.withValues(alpha: 0.5), - ), + Row( + children: [ + Text(emoji, style: const TextStyle(fontSize: 20)), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + Text( + subtitle, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + ], ), + const SizedBox(height: DesignTokens.space3), + child, ], ), ); } - Widget _buildCategorySection( - PreferenceController prefController, - ThemeService themeService, - ) { - return Obx(() { - final categories = prefController.availableCategories; - if (categories.isEmpty) { - return const Padding( - padding: EdgeInsets.all(20), - child: Text('暂无分类数据', style: TextStyle(fontSize: 14)), - ); - } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: categories.map((cat) { - return _buildCategoryGroup(cat, prefController, themeService); - }).toList(), - ), - ); - }); + Widget _buildDietTypeCard(bool isDark) { + return _buildCard( + isDark: isDark, + title: '饮食类型', + subtitle: '选择你的饮食方式', + emoji: '🥗', + child: Obx(() => Wrap( + spacing: DesignTokens.space2, + runSpacing: DesignTokens.space2, + children: DietType.values.map((type) { + final isSelected = _tasteService.dietType.value == type; + return _buildSelectionChip( + label: '${type.emoji} ${type.label}', + isSelected: isSelected, + isDark: isDark, + onTap: () => _tasteService.saveDietType(type), + ); + }).toList(), + )), + ); } - Widget _buildCategoryGroup( - PreferenceCategory cat, - PreferenceController prefController, - ThemeService themeService, + Widget _buildSpiceCard(bool isDark) { + return _buildCard( + isDark: isDark, + title: '辣度偏好', + subtitle: '选择你能接受的辣度', + emoji: '🌶️', + child: Obx(() => Wrap( + spacing: DesignTokens.space2, + runSpacing: DesignTokens.space2, + children: SpiceLevel.values.map((level) { + final isSelected = _tasteService.spiceLevel.value == level; + return _buildSelectionChip( + label: '${level.emoji} ${level.label}', + isSelected: isSelected, + isDark: isDark, + onTap: () => _tasteService.saveSpiceLevel(level), + ); + }).toList(), + )), + ); + } + + Widget _buildTasteCard(bool isDark) { + return _buildCard( + isDark: isDark, + title: '口味偏好', + subtitle: '调节你偏好的口味程度', + emoji: '👅', + child: Obx(() => Column( + children: [ + _buildTasteSlider( + '甜度', + '🍬', + _tasteService.sweetness.value, + (v) => _tasteService.saveSweetness(v), + isDark, + ), + const SizedBox(height: DesignTokens.space3), + _buildTasteSlider( + '咸度', + '🧂', + _tasteService.saltiness.value, + (v) => _tasteService.saveSaltiness(v), + isDark, + ), + const SizedBox(height: DesignTokens.space3), + _buildTasteSlider( + '酸度', + '🍋', + _tasteService.sourness.value, + (v) => _tasteService.saveSourness(v), + isDark, + ), + ], + )), + ); + } + + Widget _buildTasteSlider( + String label, + String emoji, + double value, + ValueChanged onChanged, + bool isDark, ) { - final isSelected = prefController.isCategoryPreferred(cat.id); + return Row( + children: [ + Text(emoji, style: const TextStyle(fontSize: 16)), + const SizedBox(width: DesignTokens.space2), + SizedBox( + width: 36, + child: Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ), + Expanded( + child: CupertinoSlider( + value: value, + onChanged: onChanged, + activeColor: DesignTokens.dynamicPrimary, + min: 0, + max: 1, + divisions: 10, + ), + ), + SizedBox( + width: 32, + child: Text( + '${(value * 100).round()}%', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + textAlign: TextAlign.end, + ), + ), + ], + ); + } + + Widget _buildCookingLevelCard(bool isDark) { + return _buildCard( + isDark: isDark, + title: '烹饪水平', + subtitle: '帮助我们推荐适合的菜谱难度', + emoji: '👨‍🍳', + child: Obx(() => Wrap( + spacing: DesignTokens.space2, + runSpacing: DesignTokens.space2, + children: CookingLevel.values.map((level) { + final isSelected = _tasteService.cookingLevel.value == level; + return _buildSelectionChip( + label: '${level.emoji} ${level.label}', + isSelected: isSelected, + isDark: isDark, + onTap: () => _tasteService.saveCookingLevel(level), + ); + }).toList(), + )), + ); + } + + Widget _buildServingSizeCard(bool isDark) { + return _buildCard( + isDark: isDark, + title: '每餐人数', + subtitle: '默认份量参考', + emoji: '🍚', + child: Obx(() => Wrap( + spacing: DesignTokens.space2, + runSpacing: DesignTokens.space2, + children: ServingSize.values.map((size) { + final isSelected = _tasteService.servingSize.value == size; + return _buildSelectionChip( + label: '${size.emoji} ${size.label}', + isSelected: isSelected, + isDark: isDark, + onTap: () => _tasteService.saveServingSize(size), + ); + }).toList(), + )), + ); + } + + Widget _buildHealthGoalCard(bool isDark) { + return _buildCard( + isDark: isDark, + title: '健康目标', + subtitle: '可多选,影响营养推荐', + emoji: '🎯', + child: Obx(() => Wrap( + spacing: DesignTokens.space2, + runSpacing: DesignTokens.space2, + children: HealthGoal.values.map((goal) { + final isSelected = _tasteService.healthGoals.contains(goal); + return _buildSelectionChip( + label: '${goal.emoji} ${goal.label}', + isSelected: isSelected, + isDark: isDark, + isMultiSelect: true, + onTap: () { + final goals = Set.from(_tasteService.healthGoals); + if (isSelected) { + goals.remove(goal); + } else { + goals.add(goal); + } + _tasteService.saveHealthGoals(goals); + }, + ); + }).toList(), + )), + ); + } + + Widget _buildCategoryCard(bool isDark) { + return _buildCard( + isDark: isDark, + title: '偏好菜系', + subtitle: '选择你喜欢的菜系分类', + emoji: '📂', + child: Obx(() { + final categories = _controller.availableCategories; + if (categories.isEmpty) { + return Text( + '暂无分类数据', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: categories.map((cat) { + return _buildCategoryGroup(cat, isDark); + }).toList(), + ); + }), + ); + } + + Widget _buildCategoryGroup(PreferenceCategory cat, bool isDark) { + final isSelected = _controller.isCategoryPreferred(cat.id); final hasChildren = cat.children.isNotEmpty; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.only(top: 8, bottom: 4), - child: _buildChip( - label: '${cat.displayIcon} ${cat.name}', - icon: '', - isSelected: isSelected, - themeService: themeService, - onTap: () => prefController.toggleCategory(cat.id), - ), + _buildSelectionChip( + label: '${cat.displayIcon} ${cat.name}', + isSelected: isSelected, + isDark: isDark, + onTap: () => _controller.toggleCategory(cat.id), ), if (hasChildren) Padding( - padding: const EdgeInsets.only(left: 20), + padding: const EdgeInsets.only( + left: DesignTokens.space5, + top: DesignTokens.space2, + ), child: Wrap( - spacing: 8, - runSpacing: 8, + spacing: DesignTokens.space2, + runSpacing: DesignTokens.space2, children: cat.children.map((subCat) { - final isSubSelected = prefController.isCategoryPreferred( + final isSubSelected = _controller.isCategoryPreferred( subCat.id, ); - return _buildChip( + return _buildSelectionChip( label: '${subCat.displayIcon} ${subCat.name}', - icon: '', isSelected: isSubSelected, - themeService: themeService, - onTap: () => prefController.toggleCategory(subCat.id), + isDark: isDark, + onTap: () => _controller.toggleCategory(subCat.id), ); }).toList(), ), ), - const SizedBox(height: 8), + const SizedBox(height: DesignTokens.space2), ], ); } - Widget _buildTagSection( - PreferenceController prefController, - ThemeService themeService, - ) { - return Obx(() { - final availableTags = prefController.availableTags; - if (availableTags.isEmpty) { - return Padding( - padding: const EdgeInsets.all(20), - child: Text( - '暂无标签数据', - style: TextStyle( - fontSize: 14, - color: themeService.textColor.value.withValues(alpha: 0.5), - ), - ), - ); - } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: availableTags.map((tag) { - final isSelected = prefController.isTagPreferred(tag.id); - return _buildChip( - label: '🏷️ ${tag.name}', - icon: '', - isSelected: isSelected, - themeService: themeService, - onTap: () => prefController.toggleTag(tag.id), - ); - }).toList(), - ), - ); - }); - } - - Widget _buildAllergenSection( - PreferenceController prefController, - ThemeService themeService, - ) { - return Obx(() { - final allergens = prefController.availableAllergens; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Wrap( - spacing: 8, - runSpacing: 8, + Widget _buildAllergenCard(bool isDark) { + return _buildCard( + isDark: isDark, + title: '过敏原屏蔽', + subtitle: '屏蔽含这些食材的菜谱', + emoji: '⚠️', + child: Obx(() { + final allergens = _controller.availableAllergens; + return Wrap( + spacing: DesignTokens.space2, + runSpacing: DesignTokens.space2, children: allergens.map((allergen) { - final isBlocked = prefController.isAllergenBlocked(allergen.type); - return _buildChip( + final isBlocked = _controller.isAllergenBlocked(allergen.type); + return _buildSelectionChip( label: '${allergen.icon ?? '⚠️'} ${allergen.name}', - icon: '', isSelected: isBlocked, + isDark: isDark, isDestructive: true, - themeService: themeService, - onTap: () => prefController.toggleAllergen(allergen.type), + onTap: () => _controller.toggleAllergen(allergen.type), ); }).toList(), + ); + }), + ); + } + + Widget _buildSummaryCard(bool isDark) { + return Obx(() { + final diet = _tasteService.dietType.value; + final spice = _tasteService.spiceLevel.value; + final cooking = _tasteService.cookingLevel.value; + final serving = _tasteService.servingSize.value; + final goals = _tasteService.healthGoals; + + final parts = []; + parts.add('${diet.emoji} ${diet.label}'); + parts.add('${spice.emoji} ${spice.label}'); + parts.add('${cooking.emoji} ${cooking.label}'); + parts.add('${serving.emoji} ${serving.label}'); + if (goals.isNotEmpty) { + parts.add('🎯 ${goals.length}个目标'); + } + if (_controller.blockedAllergenTypes.isNotEmpty) { + parts.add('⚠️ ${_controller.blockedAllergenTypes.length}个屏蔽'); + } + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + DesignTokens.dynamicPrimary.withValues(alpha: 0.08), + DesignTokens.dynamicPrimary.withValues(alpha: 0.03), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.15), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + CupertinoIcons.checkmark_seal_fill, + size: 18, + color: DesignTokens.dynamicPrimary, + ), + const SizedBox(width: DesignTokens.space2), + Text( + '偏好摘要', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + Wrap( + spacing: DesignTokens.space2, + runSpacing: DesignTokens.space1, + children: parts.map((part) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + part, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, + fontWeight: FontWeight.w500, + ), + ), + ); + }).toList(), + ), + ], ), ); }); } - Widget _buildChip({ + Widget _buildSelectionChip({ required String label, - required String icon, required bool isSelected, - required ThemeService themeService, + required bool isDark, required VoidCallback onTap, bool isDestructive = false, + bool isMultiSelect = false, }) { return GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), decoration: BoxDecoration( color: isSelected ? (isDestructive ? CupertinoColors.systemRed.withValues(alpha: 0.12) - : themeService.primaryColor.value.withValues(alpha: 0.12)) - : themeService.backgroundColor.value, - borderRadius: DesignTokens.borderRadiusLg, + : DesignTokens.dynamicPrimary.withValues(alpha: 0.12)) + : (isDark + ? DarkDesignTokens.background + : DesignTokens.background), + borderRadius: DesignTokens.borderRadiusMd, border: Border.all( color: isSelected ? (isDestructive ? CupertinoColors.systemRed.withValues(alpha: 0.4) - : themeService.primaryColor.value.withValues(alpha: 0.4)) - : themeService.textColor.value.withValues(alpha: 0.15), + : DesignTokens.dynamicPrimary.withValues(alpha: 0.4)) + : (isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.2) + : DesignTokens.text3.withValues(alpha: 0.15)), width: 1, ), ), @@ -265,27 +574,29 @@ class PreferencePage extends StatelessWidget { children: [ if (isSelected) Padding( - padding: const EdgeInsets.only(right: 4), + padding: const EdgeInsets.only(right: DesignTokens.space1), child: Icon( isDestructive ? CupertinoIcons.shield_fill - : CupertinoIcons.checkmark_circle_fill, + : (isMultiSelect + ? CupertinoIcons.checkmark_square_fill + : CupertinoIcons.checkmark_circle_fill), size: 14, color: isDestructive ? CupertinoColors.systemRed - : themeService.primaryColor.value, + : DesignTokens.dynamicPrimary, ), ), Text( - icon.isNotEmpty ? '$icon $label' : label, + label, style: TextStyle( - fontSize: 13, + fontSize: DesignTokens.fontSm, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, color: isSelected ? (isDestructive ? CupertinoColors.systemRed - : themeService.primaryColor.value) - : themeService.textColor.value.withValues(alpha: 0.7), + : DesignTokens.dynamicPrimary) + : (isDark ? DarkDesignTokens.text2 : DesignTokens.text2), ), ), ], @@ -294,15 +605,12 @@ class PreferencePage extends StatelessWidget { ); } - void _showClearConfirm( - BuildContext context, - PreferenceController prefController, - ) { + void _showClearConfirm(BuildContext context, bool isDark) { showCupertinoDialog( context: context, builder: (context) => CupertinoAlertDialog( title: const Text('重置偏好'), - content: const Text('确定要清除所有口味偏好设置吗?'), + content: const Text('确定要清除所有口味偏好设置吗?此操作不可撤销。'), actions: [ CupertinoDialogAction( isDefaultAction: true, @@ -313,7 +621,8 @@ class PreferencePage extends StatelessWidget { isDestructiveAction: true, onPressed: () { Navigator.pop(context); - prefController.clearAll(); + _tasteService.clearAll(); + _controller.clearAll(); }, child: const Text('重置'), ), diff --git a/lib/src/pages/profile/social/favorites_page.dart b/lib/src/pages/profile/social/favorites_page.dart index 20e0bfc..d270486 100644 --- a/lib/src/pages/profile/social/favorites_page.dart +++ b/lib/src/pages/profile/social/favorites_page.dart @@ -112,7 +112,7 @@ class _FavoritesPageState extends State children: [ CustomScrollView( controller: _scrollController, - physics: const ClampingScrollPhysics( + physics: const BouncingScrollPhysics( parent: AlwaysScrollableScrollPhysics(), ), slivers: [ @@ -1166,7 +1166,7 @@ class _ScrollEndIndicatorState extends State<_ScrollEndIndicator> { void _onScroll() { final isVisible = widget.scrollController.position.pixels >= - widget.scrollController.position.maxScrollExtent - 50; + widget.scrollController.position.maxScrollExtent - 80; if (_isVisible != isVisible) { setState(() => _isVisible = isVisible); } @@ -1177,19 +1177,56 @@ class _ScrollEndIndicatorState extends State<_ScrollEndIndicator> { if (!_isVisible) return const SizedBox(height: 20); return Container( - padding: const EdgeInsets.symmetric(vertical: DesignTokens.space3), + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space5), alignment: Alignment.center, - child: Row( + child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 30, - height: 3, - decoration: BoxDecoration( + 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 - : DesignTokens.text3.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(2), + ? DarkDesignTokens.text3.withValues(alpha: 0.6) + : DesignTokens.text3.withValues(alpha: 0.6), ), ), ], diff --git a/lib/src/pages/tools/farm/farm_shop_page.dart b/lib/src/pages/tools/farm/farm_shop_page.dart index cba82e0..87d1a05 100644 --- a/lib/src/pages/tools/farm/farm_shop_page.dart +++ b/lib/src/pages/tools/farm/farm_shop_page.dart @@ -34,7 +34,9 @@ class _FarmShopPageState extends State { final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; return CupertinoPageScaffold( - backgroundColor: isDark ? DarkDesignTokens.background : DesignTokens.background, + backgroundColor: isDark + ? DarkDesignTokens.background + : DesignTokens.background, child: SafeArea( child: Column( children: [ @@ -62,8 +64,11 @@ class _FarmShopPageState extends State { : DesignTokens.text3.withValues(alpha: 0.08), borderRadius: DesignTokens.borderRadiusMd, ), - child: Icon(CupertinoIcons.back, size: 20, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1), + child: Icon( + CupertinoIcons.back, + size: 20, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), ), ), const SizedBox(width: DesignTokens.space3), @@ -79,13 +84,17 @@ class _FarmShopPageState extends State { color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, ), ), - Obx(() => Text( - '💰 ${_gameController.player.value.gold} 金币', - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, - ), - )), + Obx( + () => Text( + '💰 ${_gameController.player.value.gold} 金币', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ), ], ), ), @@ -103,7 +112,8 @@ class _FarmShopPageState extends State { separatorBuilder: (context, index) => const SizedBox(height: 12), itemBuilder: (context, index) { final crop = crops[index]; - final isUnlocked = _gameController.player.value.unlockedCrops.contains(crop.id); + final isUnlocked = _gameController.player.value.unlockedCrops + .contains(crop.id); return _buildCropCard(crop, isUnlocked, isDark); }, ); @@ -123,86 +133,96 @@ class _FarmShopPageState extends State { ), boxShadow: DesignTokens.shadowsMd, ), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Center( - child: Text(crop.emoji, style: const TextStyle(fontSize: 36)), - ), - ), - const SizedBox(width: DesignTokens.space4), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - crop.name, - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - if (!isUnlocked) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: DesignTokens.orange.withValues(alpha: 0.2), - borderRadius: DesignTokens.borderRadiusSm, - ), - child: Text( - 'Lv.${crop.unlockLevel} 解锁', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: DesignTokens.orange, - ), - ), - ), - ], - ], + Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, ), - const SizedBox(height: 8), - Row( - children: [ - _buildInfoTag('⏱️', '${crop.growthTime} 分钟', isDark), - const SizedBox(width: 8), - _buildInfoTag('💰', '收获 ${crop.harvestPrice}', isDark), - const SizedBox(width: 8), - _buildInfoTag('⭐', '+${crop.harvestExp} EXP', isDark), - ], - ), - ], - ), - ), - const SizedBox(width: DesignTokens.space3), - SizedBox( - width: 80, - child: CupertinoButton( - padding: EdgeInsets.zero, - color: isUnlocked ? DesignTokens.dynamicPrimary : DesignTokens.text3, - borderRadius: DesignTokens.borderRadiusMd, - onPressed: isUnlocked ? () => _shopController.buySeed(crop.id) : null, - child: Text( - '${crop.seedPrice}💰', - style: const TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - color: CupertinoColors.white, + child: Center( + child: Text(crop.emoji, style: const TextStyle(fontSize: 28)), ), ), - ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + crop.name, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + if (!isUnlocked) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: DesignTokens.orange.withValues(alpha: 0.2), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + 'Lv.${crop.unlockLevel} 解锁', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.orange, + ), + ), + ), + ], + ], + ), + ], + ), + ), + SizedBox( + width: 80, + child: CupertinoButton( + padding: EdgeInsets.zero, + color: isUnlocked + ? DesignTokens.dynamicPrimary + : DesignTokens.text3, + borderRadius: DesignTokens.borderRadiusMd, + onPressed: isUnlocked + ? () => _shopController.buySeed(crop.id) + : null, + child: Text( + '${crop.seedPrice}💰', + style: const TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: CupertinoColors.white, + ), + ), + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + _buildInfoTag('⏱️', '${crop.growthTime}分钟', isDark), + _buildInfoTag('💰', '收获${crop.harvestPrice}', isDark), + _buildInfoTag('⭐', '+${crop.harvestExp}EXP', isDark), + ], ), ], ), diff --git a/lib/src/services/data/data_export_service.dart b/lib/src/services/data/data_export_service.dart index cddd2b8..1f33fd7 100644 --- a/lib/src/services/data/data_export_service.dart +++ b/lib/src/services/data/data_export_service.dart @@ -239,8 +239,8 @@ class DataExportService extends GetxService { if (kIsWeb) return; await Share.shareXFiles( [XFile(filePath)], - subject: '妈妈厨房 - 数据导出', - text: '从妈妈厨房导出的数据', + subject: '小妈厨房 - 数据导出', + text: '从小妈厨房导出的数据', ); } diff --git a/lib/src/services/data/recipe_share_service.dart b/lib/src/services/data/recipe_share_service.dart new file mode 100644 index 0000000..384fb74 --- /dev/null +++ b/lib/src/services/data/recipe_share_service.dart @@ -0,0 +1,109 @@ +/* + * 文件: recipe_share_service.dart + * 名称: 菜谱分享数据推送服务 + * 作用: 将菜谱数据推送到 recipe_share.php 本地存储,供扫码后展示 + * 创建: 2026-04-18 + * 更新: 2026-04-18 使用 toJson() 传输完整菜谱数据 + */ + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:mom_kitchen/src/config/api_config.dart'; +import 'package:mom_kitchen/src/models/recipe/recipe_model.dart'; + +/// 菜谱分享数据推送服务 +class RecipeShareService { + static final RecipeShareService _instance = RecipeShareService._internal(); + factory RecipeShareService() => _instance; + RecipeShareService._internal(); + + final Dio _dio = Dio(); + + /// 推送菜谱数据到分享页面 + /// 返回分享URL + Future pushRecipeToShare(RecipeModel recipe) async { + try { + final response = await _dio.post( + '${ApiConfig.baseUrl}/kitchen/recipe_share.php', + queryParameters: {'act': 'create'}, + data: recipe.toJson(), + options: Options( + contentType: 'application/json', + responseType: ResponseType.json, + ), + ); + + if (response.statusCode == 200) { + final data = response.data as Map; + if (data['code'] == 200) { + debugPrint('✅ 菜谱分享数据推送成功: ${recipe.title}'); + return _buildShareUrl(recipe); + } else { + debugPrint('❌ 菜谱分享数据推送失败: ${data['message']}'); + } + } + } catch (e) { + debugPrint('❌ 菜谱分享数据推送异常: $e'); + } + return null; + } + + /// 更新菜谱分享数据 + Future updateRecipeShare(RecipeModel recipe) async { + try { + final response = await _dio.post( + '${ApiConfig.baseUrl}/kitchen/recipe_share.php', + queryParameters: {'act': 'update'}, + data: recipe.toJson(), + options: Options( + contentType: 'application/json', + responseType: ResponseType.json, + ), + ); + + if (response.statusCode == 200) { + final data = response.data as Map; + if (data['code'] == 200) { + debugPrint('✅ 菜谱分享数据更新成功: ${recipe.title}'); + return true; + } else { + debugPrint('❌ 菜谱分享数据更新失败: ${data['message']}'); + } + } + } catch (e) { + debugPrint('❌ 菜谱分享数据更新异常: $e'); + } + return false; + } + + /// 删除菜谱分享数据 + Future deleteRecipeShare(int recipeId) async { + try { + final response = await _dio.get( + '${ApiConfig.baseUrl}/kitchen/recipe_share.php', + queryParameters: {'act': 'delete', 'id': recipeId}, + ); + + if (response.statusCode == 200) { + final data = response.data as Map; + if (data['code'] == 200) { + debugPrint('✅ 菜谱分享数据删除成功: ID=$recipeId'); + return true; + } else { + debugPrint('❌ 菜谱分享数据删除失败: ${data['message']}'); + } + } + } catch (e) { + debugPrint('❌ 菜谱分享数据删除异常: $e'); + } + return false; + } + + /// 构建分享URL + String _buildShareUrl(RecipeModel recipe) { + if (recipe.code != null && recipe.code!.isNotEmpty) { + return '${ApiConfig.baseUrl}/kitchen/recipe_share.php?code=${Uri.encodeComponent(recipe.code!)}'; + } + return '${ApiConfig.baseUrl}/kitchen/recipe_share.php?id=${recipe.id}'; + } +} diff --git a/lib/src/services/user/taste_preference_service.dart b/lib/src/services/user/taste_preference_service.dart new file mode 100644 index 0000000..dfd3c9e --- /dev/null +++ b/lib/src/services/user/taste_preference_service.dart @@ -0,0 +1,324 @@ +/* + * 文件: taste_preference_service.dart + * 名称: 口味偏好持久化服务 + * 作用: 将用户口味偏好(饮食类型/辣度/口味/烹饪水平/人数/健康目标)持久化到 SharedPreferences + * 创建时间: 2026-04-18 + * 更新时间: 2026-04-18 初始实现 + */ + +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum DietType { + normal('普通饮食', '🍽️'), + vegetarian('素食', '🥬'), + vegan('纯素', '🌱'), + lowCarb('低碳水', '🥑'), + keto('生酮', '🥩'), + mediterranean('地中海', '🫒'), + halal('清真', '🕌'); + + final String label; + final String emoji; + const DietType(this.label, this.emoji); + + static DietType fromName(String? name) { + if (name == null) return DietType.normal; + return DietType.values.firstWhere( + (e) => e.name == name, + orElse: () => DietType.normal, + ); + } +} + +enum CookingLevel { + beginner('厨房小白', '👶'), + entry('入门选手', '🍳'), + skilled('熟练厨师', '👨‍🍳'), + master('厨艺大师', '👑'); + + final String label; + final String emoji; + const CookingLevel(this.label, this.emoji); + + static CookingLevel fromName(String? name) { + if (name == null) return CookingLevel.beginner; + return CookingLevel.values.firstWhere( + (e) => e.name == name, + orElse: () => CookingLevel.beginner, + ); + } +} + +enum ServingSize { + solo('1-2人', '👫'), + family('3-4人', '👨‍👩‍👧'), + party('5人+', '🎉'); + + final String label; + final String emoji; + const ServingSize(this.label, this.emoji); + + static ServingSize fromName(String? name) { + if (name == null) return ServingSize.solo; + return ServingSize.values.firstWhere( + (e) => e.name == name, + orElse: () => ServingSize.solo, + ); + } +} + +enum HealthGoal { + loseFat('减脂瘦身', '🏃'), + buildMuscle('增肌塑形', '💪'), + maintain('维持体重', '⚖️'), + controlSugar('控糖管理', '🩸'), + heartHealth('心血管健康', '❤️'), + gutHealth('肠胃养护', '🫁'), + immunity('增强免疫', '🛡️'), + boneHealth('骨骼健康', '🦴'); + + final String label; + final String emoji; + const HealthGoal(this.label, this.emoji); + + static HealthGoal fromName(String? name) { + if (name == null) return HealthGoal.maintain; + return HealthGoal.values.firstWhere( + (e) => e.name == name, + orElse: () => HealthGoal.maintain, + ); + } +} + +enum SpiceLevel { + none('不吃辣', '😶'), + mild('微辣', '🌶️'), + medium('中辣', '🌶️🌶️'), + hot('重辣', '🌶️🌶️🌶️'), + extreme('变态辣', '🔥🔥🔥'); + + final String label; + final String emoji; + const SpiceLevel(this.label, this.emoji); + + static SpiceLevel fromName(String? name) { + if (name == null) return SpiceLevel.mild; + return SpiceLevel.values.firstWhere( + (e) => e.name == name, + orElse: () => SpiceLevel.mild, + ); + } +} + +class TastePreferenceService extends GetxService { + static TastePreferenceService get to => Get.find(); + + final _keyDietType = 'taste_diet_type'; + final _keySpiceLevel = 'taste_spice_level'; + final _keySweetness = 'taste_sweetness'; + final _keySaltiness = 'taste_saltiness'; + final _keySourness = 'taste_sourness'; + final _keyCookingLevel = 'taste_cooking_level'; + final _keyServingSize = 'taste_serving_size'; + final _keyHealthGoals = 'taste_health_goals'; + + final dietType = DietType.normal.obs; + final spiceLevel = SpiceLevel.mild.obs; + final sweetness = 0.5.obs; + final saltiness = 0.5.obs; + final sourness = 0.3.obs; + final cookingLevel = CookingLevel.beginner.obs; + final servingSize = ServingSize.solo.obs; + final healthGoals = {}.obs; + + @override + void onInit() { + super.onInit(); + _loadAll(); + } + + Future _loadAll() async { + final prefs = await SharedPreferences.getInstance(); + dietType.value = DietType.fromName(prefs.getString(_keyDietType)); + spiceLevel.value = SpiceLevel.fromName(prefs.getString(_keySpiceLevel)); + sweetness.value = prefs.getDouble(_keySweetness) ?? 0.5; + saltiness.value = prefs.getDouble(_keySaltiness) ?? 0.5; + sourness.value = prefs.getDouble(_keySourness) ?? 0.3; + cookingLevel.value = CookingLevel.fromName( + prefs.getString(_keyCookingLevel), + ); + servingSize.value = ServingSize.fromName(prefs.getString(_keyServingSize)); + + final goalsStr = prefs.getStringList(_keyHealthGoals) ?? []; + healthGoals.assignAll(goalsStr.map((n) => HealthGoal.fromName(n)).toSet()); + } + + Future saveDietType(DietType value) async { + dietType.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyDietType, value.name); + } + + Future saveSpiceLevel(SpiceLevel value) async { + spiceLevel.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keySpiceLevel, value.name); + } + + Future saveSweetness(double value) async { + sweetness.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_keySweetness, value); + } + + Future saveSaltiness(double value) async { + saltiness.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_keySaltiness, value); + } + + Future saveSourness(double value) async { + sourness.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_keySourness, value); + } + + Future saveCookingLevel(CookingLevel value) async { + cookingLevel.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyCookingLevel, value.name); + } + + Future saveServingSize(ServingSize value) async { + servingSize.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyServingSize, value.name); + } + + Future saveHealthGoals(Set goals) async { + healthGoals.assignAll(goals); + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + _keyHealthGoals, + goals.map((g) => g.name).toList(), + ); + } + + Future clearAll() async { + final prefs = await SharedPreferences.getInstance(); + await Future.wait([ + prefs.remove(_keyDietType), + prefs.remove(_keySpiceLevel), + prefs.remove(_keySweetness), + prefs.remove(_keySaltiness), + prefs.remove(_keySourness), + prefs.remove(_keyCookingLevel), + prefs.remove(_keyServingSize), + prefs.remove(_keyHealthGoals), + ]); + dietType.value = DietType.normal; + spiceLevel.value = SpiceLevel.mild; + sweetness.value = 0.5; + saltiness.value = 0.5; + sourness.value = 0.3; + cookingLevel.value = CookingLevel.beginner; + servingSize.value = ServingSize.solo; + healthGoals.clear(); + } + + String get summary { + final parts = []; + parts.add('${dietType.value.emoji} ${dietType.value.label}'); + parts.add('${spiceLevel.value.emoji} ${spiceLevel.value.label}'); + parts.add('${cookingLevel.value.emoji} ${cookingLevel.value.label}'); + parts.add('${servingSize.value.emoji} ${servingSize.value.label}'); + if (healthGoals.isNotEmpty) { + parts.add('🎯 ${healthGoals.length}个目标'); + } + return parts.join(' '); + } + + bool get hasCustomPreferences { + return dietType.value != DietType.normal || + spiceLevel.value != SpiceLevel.mild || + sweetness.value != 0.5 || + saltiness.value != 0.5 || + sourness.value != 0.3 || + cookingLevel.value != CookingLevel.beginner || + servingSize.value != ServingSize.solo || + healthGoals.isNotEmpty; + } + + double matchSpiceLevel(String? recipeTaste) { + if (recipeTaste == null || recipeTaste.isEmpty) return -1; + final taste = recipeTaste.toLowerCase(); + final spice = spiceLevel.value; + + final spiceKeywords = { + SpiceLevel.none: ['清淡', '不辣', '原味', '清蒸', '白灼'], + SpiceLevel.mild: ['微辣', '小辣', '香辣', '酱香'], + SpiceLevel.medium: ['中辣', '麻辣', '酸辣', '香辣'], + SpiceLevel.hot: ['重辣', '特辣', '干锅', '水煮', '爆辣'], + SpiceLevel.extreme: ['变态辣', '地狱辣', '超辣', '魔鬼辣'], + }; + + if (spiceKeywords[spice]?.any((k) => taste.contains(k)) == true) { + return 1.0; + } + if (spice == SpiceLevel.none && !taste.contains('辣')) return 0.8; + if (spice.index >= SpiceLevel.medium.index && taste.contains('辣')) { + return 0.6; + } + return 0.3; + } + + double matchDifficulty(String? recipeDifficulty) { + if (recipeDifficulty == null || recipeDifficulty.isEmpty) return -1; + final diff = recipeDifficulty.toLowerCase(); + final level = cookingLevel.value; + + final diffKeywords = { + CookingLevel.beginner: ['简单', '入门', '初级', '快手', '零失败', '新手'], + CookingLevel.entry: ['普通', '中等', '一般', '家常'], + CookingLevel.skilled: ['较难', '进阶', '高级', '复杂'], + CookingLevel.master: ['困难', '大师', '专业', '挑战'], + }; + + if (diffKeywords[level]?.any((k) => diff.contains(k)) == true) return 1.0; + return 0.4; + } + + double matchTaste(String? recipeTaste) { + if (recipeTaste == null || recipeTaste.isEmpty) return -1; + final taste = recipeTaste.toLowerCase(); + double score = 0; + int count = 0; + + if (sweetness.value > 0.6 && taste.contains('甜')) { + score += 1; + count++; + } else if (sweetness.value < 0.3 && !taste.contains('甜')) { + score += 0.5; + count++; + } + + if (saltiness.value > 0.6 && (taste.contains('咸') || taste.contains('酱'))) { + score += 1; + count++; + } else if (saltiness.value < 0.3 && !taste.contains('咸')) { + score += 0.5; + count++; + } + + if (sourness.value > 0.6 && taste.contains('酸')) { + score += 1; + count++; + } else if (sourness.value < 0.3 && !taste.contains('酸')) { + score += 0.5; + count++; + } + + return count > 0 ? score / count : 0.5; + } +} diff --git a/lib/src/utils/platform_utils.dart b/lib/src/utils/platform_utils.dart index 0d89589..159cbbf 100644 --- a/lib/src/utils/platform_utils.dart +++ b/lib/src/utils/platform_utils.dart @@ -1,5 +1,6 @@ // 2026-04-09 | PlatformUtils | 平台工具类 | 判断运行平台,兼容Web // 2026-04-09 | 修复Web平台Platform API崩溃问题 +// 2026-04-18 | 修复鸿蒙端检测:使用动态检测避免非鸿蒙平台崩溃 import 'dart:io' if (dart.library.html) 'platform_web_stub.dart'; import 'package:flutter/foundation.dart' show kIsWeb; @@ -15,13 +16,7 @@ class PlatformUtils { bool get isAndroid => !kIsWeb && Platform.isAndroid; - bool get isHarmonyOS { - if (kIsWeb || !Platform.isAndroid) return false; - final version = Platform.operatingSystemVersion.toLowerCase(); - return version.contains('harmony') || - version.contains('ohos') || - version.contains('openharmony'); - } + bool get isHarmonyOS => _checkIsHarmonyOS(); bool get isWindows => !kIsWeb && Platform.isWindows; @@ -31,9 +26,26 @@ class PlatformUtils { bool get isFuchsia => !kIsWeb && Platform.isFuchsia; + static bool? _isHarmonyOS; + + static bool _checkIsHarmonyOS() { + if (kIsWeb) return false; + if (_isHarmonyOS != null) return _isHarmonyOS!; + + try { + // 动态检测 Platform.isOhos(仅鸿蒙平台存在) + _isHarmonyOS = (Platform as dynamic).isOhos == true; + return _isHarmonyOS!; + } catch (_) { + // 非 HarmonyOS 平台,isOhos 不存在 + } + _isHarmonyOS = false; + return false; + } + String get operatingSystemName { if (kIsWeb) return 'Web'; - if (isHarmonyOS) return 'HarmonyOS'; + if (_checkIsHarmonyOS()) return 'HarmonyOS'; if (Platform.isIOS) return 'iOS'; if (Platform.isAndroid) return 'Android'; if (Platform.isWindows) return 'Windows'; @@ -48,17 +60,13 @@ class PlatformUtils { String get dartVersion => Platform.version; - String get localHostname => - kIsWeb ? 'web' : Platform.localHostname; + String get localHostname => kIsWeb ? 'web' : Platform.localHostname; - Map get environment => - kIsWeb ? {} : Platform.environment; + Map get environment => kIsWeb ? {} : Platform.environment; - String get executable => - kIsWeb ? '' : Platform.executable; + String get executable => kIsWeb ? '' : Platform.executable; - String get resolvedExecutable => - kIsWeb ? '' : Platform.resolvedExecutable; + String get resolvedExecutable => kIsWeb ? '' : Platform.resolvedExecutable; String get script => kIsWeb ? '' : Platform.script.toString(); @@ -77,11 +85,9 @@ class PlatformUtils { bool get isGoogle => isAndroid; - int get numberOfProcessors => - kIsWeb ? 1 : Platform.numberOfProcessors; + int get numberOfProcessors => kIsWeb ? 1 : Platform.numberOfProcessors; - String get pathSeparator => - kIsWeb ? '/' : Platform.pathSeparator; + String get pathSeparator => kIsWeb ? '/' : Platform.pathSeparator; String get lineTerminator { if (kIsWeb) return '\n'; @@ -98,7 +104,8 @@ class PlatformUtils { } bool hasFeature(String feature) { - return systemFeatures - .any((f) => f.toLowerCase().contains(feature.toLowerCase())); + return systemFeatures.any( + (f) => f.toLowerCase().contains(feature.toLowerCase()), + ); } } diff --git a/lib/src/utils/platform_web_stub.dart b/lib/src/utils/platform_web_stub.dart index a95c9ba..97b1176 100644 --- a/lib/src/utils/platform_web_stub.dart +++ b/lib/src/utils/platform_web_stub.dart @@ -1,4 +1,5 @@ // 2026-04-09 | platform_web_stub.dart | Web平台dart:io桩 | 提供Web端编译所需的类型桩 +// 2026-04-18 | 新增 isOhos / operatingSystem 属性 class Platform { static bool get isIOS => false; static bool get isAndroid => false; @@ -6,6 +7,8 @@ class Platform { static bool get isMacOS => false; static bool get isLinux => false; static bool get isFuchsia => false; + static bool get isOhos => false; + static String get operatingSystem => 'web'; static String get operatingSystemVersion => 'web'; static String get version => ''; static String get localHostname => 'web'; diff --git a/lib/src/widgets/recipe_detail/info/recipe_taste_preference.dart b/lib/src/widgets/recipe_detail/info/recipe_taste_preference.dart new file mode 100644 index 0000000..16d296c --- /dev/null +++ b/lib/src/widgets/recipe_detail/info/recipe_taste_preference.dart @@ -0,0 +1,288 @@ +/* + * 文件: recipe_taste_preference.dart + * 名称: 菜谱口味偏好标注组件 + * 作用: 在菜品详情页标注用户口味偏好设置值,对比菜品信息与用户偏好 + * 创建时间: 2026-04-18 + * 更新时间: 2026-04-18 初始实现,标注辣度/口味/难度/饮食类型/过敏原 + */ + +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/recipe_model.dart'; +import 'package:mom_kitchen/src/services/user/taste_preference_service.dart'; +import 'package:mom_kitchen/src/controllers/user/preference_controller.dart'; + +class RecipeTastePreference extends StatelessWidget { + final RecipeModel recipe; + + const RecipeTastePreference({super.key, required this.recipe}); + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final tasteService = Get.find(); + final prefController = Get.find(); + + final items = <_PreferenceItem>[]; + + final dietType = tasteService.dietType.value; + if (dietType != DietType.normal) { + items.add(_PreferenceItem( + emoji: '🥗', + label: '饮食类型', + userValue: '${dietType.emoji} ${dietType.label}', + matchLevel: _matchDietType(dietType, recipe), + )); + } + + final spiceLevel = tasteService.spiceLevel.value; + if (spiceLevel != SpiceLevel.mild) { + final spiceMatch = tasteService.matchSpiceLevel(recipe.meta?.taste); + items.add(_PreferenceItem( + emoji: '🌶️', + label: '辣度偏好', + userValue: '${spiceLevel.emoji} ${spiceLevel.label}', + matchLevel: spiceMatch, + recipeValue: recipe.meta?.taste, + )); + } + + final sweetness = tasteService.sweetness.value; + final saltiness = tasteService.saltiness.value; + final sourness = tasteService.sourness.value; + if (sweetness != 0.5 || saltiness != 0.5 || sourness != 0.3) { + final tasteMatch = tasteService.matchTaste(recipe.meta?.taste); + final tasteParts = []; + if (sweetness != 0.5) tasteParts.add('🍬 甜${(sweetness * 100).round()}%'); + if (saltiness != 0.5) tasteParts.add('🧂 咸${(saltiness * 100).round()}%'); + if (sourness != 0.3) tasteParts.add('🍋 酸${(sourness * 100).round()}%'); + items.add(_PreferenceItem( + emoji: '👅', + label: '口味偏好', + userValue: tasteParts.join(' '), + matchLevel: tasteMatch, + recipeValue: recipe.meta?.taste, + )); + } + + final cookingLevel = tasteService.cookingLevel.value; + if (cookingLevel != CookingLevel.beginner) { + final diffMatch = tasteService.matchDifficulty(recipe.meta?.difficulty); + items.add(_PreferenceItem( + emoji: '👨‍🍳', + label: '烹饪水平', + userValue: '${cookingLevel.emoji} ${cookingLevel.label}', + matchLevel: diffMatch, + recipeValue: recipe.meta?.difficulty, + )); + } + + final blockedAllergens = prefController.blockedAllergenTypes; + if (blockedAllergens.isNotEmpty) { + final allergenChecker = []; + for (final allergen in recipe.allergens) { + if (blockedAllergens.contains(allergen)) { + allergenChecker.add(allergen); + } + } + items.add(_PreferenceItem( + emoji: '⚠️', + label: '过敏原', + userValue: blockedAllergens.map((a) { + final allergenInfo = prefController.availableAllergens + .where((e) => e.type == a); + return allergenInfo.isNotEmpty + ? '${allergenInfo.first.icon ?? '⚠️'} ${allergenInfo.first.name}' + : a; + }).join(' '), + matchLevel: allergenChecker.isEmpty ? 1.0 : -1.0, + recipeValue: allergenChecker.isEmpty ? null : '含 ${allergenChecker.join('、')}', + isAllergen: true, + )); + } + + if (items.isEmpty) return const SizedBox(); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + 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.text3 : DesignTokens.text3) + .withValues(alpha: 0.1), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + CupertinoIcons.heart_fill, + size: 14, + color: DesignTokens.dynamicPrimary, + ), + const SizedBox(width: DesignTokens.space2), + Text( + '我的偏好', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + ...items.map((item) => _buildPreferenceRow(item, isDark)), + ], + ), + ), + ); + } + + Widget _buildPreferenceRow(_PreferenceItem item, bool isDark) { + final matchColor = item.isAllergen + ? (item.matchLevel < 0 ? CupertinoColors.systemRed : CupertinoColors.systemGreen) + : (item.matchLevel >= 0.8 + ? CupertinoColors.systemGreen + : item.matchLevel >= 0.5 + ? DesignTokens.orange + : CupertinoColors.systemRed); + final matchIcon = item.isAllergen + ? (item.matchLevel < 0 ? CupertinoIcons.xmark_shield_fill : CupertinoIcons.checkmark_shield_fill) + : (item.matchLevel >= 0.8 + ? CupertinoIcons.checkmark_circle_fill + : item.matchLevel >= 0.5 + ? CupertinoIcons.minus_circle_fill + : CupertinoIcons.xmark_circle_fill); + final matchLabel = item.isAllergen + ? (item.matchLevel < 0 ? '含过敏原' : '安全') + : (item.matchLevel >= 0.8 + ? '很匹配' + : item.matchLevel >= 0.5 + ? '一般' + : item.matchLevel >= 0 + ? '不太匹配' + : '无数据'); + + return Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 24, + child: Text(item.emoji, style: const TextStyle(fontSize: 14)), + ), + SizedBox( + width: 56, + child: Text( + item.label, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ), + Expanded( + child: Text( + item.userValue, + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: matchColor.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(matchIcon, size: 12, color: matchColor), + const SizedBox(width: 3), + Text( + matchLabel, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: matchColor, + ), + ), + ], + ), + ), + ], + ), + ); + } + + double _matchDietType(DietType dietType, RecipeModel recipe) { + final ingredientText = recipe.ingredients + .map((i) => '${i.name} ${i.amount ?? ''} ${i.unit ?? ''}') + .join(' ') + .toLowerCase(); + + switch (dietType) { + case DietType.vegetarian: + final meatKeywords = ['猪肉', '牛肉', '羊肉', '鸡肉', '鸭肉', '鱼', '虾', '蟹', '排骨', '五花肉', '培根', '火腿']; + final hasMeat = meatKeywords.any((k) => ingredientText.contains(k)); + return hasMeat ? 0.0 : 1.0; + case DietType.vegan: + final animalKeywords = ['猪肉', '牛肉', '羊肉', '鸡肉', '鸭肉', '鱼', '虾', '蟹', '蛋', '奶', '奶酪', '黄油', '蜂蜜', '排骨']; + final hasAnimal = animalKeywords.any((k) => ingredientText.contains(k)); + return hasAnimal ? 0.0 : 1.0; + case DietType.lowCarb: + final carbKeywords = ['米饭', '面条', '馒头', '面包', '土豆', '粉', '饼', '粥']; + final hasCarb = carbKeywords.any((k) => ingredientText.contains(k)); + return hasCarb ? 0.3 : 1.0; + case DietType.keto: + final carbKeywords = ['米饭', '面条', '馒头', '面包', '土豆', '粉', '饼', '粥', '糖', '淀粉']; + final hasCarb = carbKeywords.any((k) => ingredientText.contains(k)); + return hasCarb ? 0.0 : 1.0; + case DietType.mediterranean: + final medKeywords = ['橄榄油', '鱼', '番茄', '柠檬', '蒜', '洋葱', '香草']; + final hasMed = medKeywords.any((k) => ingredientText.contains(k)); + return hasMed ? 1.0 : 0.5; + case DietType.halal: + final haramKeywords = ['猪肉', '酒', '酒精', '培根', '火腿']; + final hasHaram = haramKeywords.any((k) => ingredientText.contains(k)); + return hasHaram ? 0.0 : 1.0; + case DietType.normal: + return 1.0; + } + } +} + +class _PreferenceItem { + final String emoji; + final String label; + final String userValue; + final double matchLevel; + final String? recipeValue; + final bool isAllergen; + + const _PreferenceItem({ + required this.emoji, + required this.label, + required this.userValue, + required this.matchLevel, + this.recipeValue, + this.isAllergen = false, + }); +} diff --git a/lib/src/widgets/recipe_detail/interaction/recipe_action_bar.dart b/lib/src/widgets/recipe_detail/interaction/recipe_action_bar.dart index 28f9a29..493cb24 100644 --- a/lib/src/widgets/recipe_detail/interaction/recipe_action_bar.dart +++ b/lib/src/widgets/recipe_detail/interaction/recipe_action_bar.dart @@ -2,8 +2,10 @@ // 2026-04-12 | API v3.2.0: recommend改为rate评分接口(1-5分) // 2026-04-13 | 新增IP状态显示,评分前显示剩余次数;新增二维码海报按钮 // 2026-04-16 | 移除点赞按钮(已移至标题区域RecipeTitleSection) +// 2026-04-18 | 新增recipe参数,支持完整菜谱数据传递到二维码海报 import 'package:flutter/cupertino.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/models/recipe/recipe_model.dart'; import 'package:mom_kitchen/src/widgets/recipe_detail/interaction/recipe_qr_poster.dart'; class RecipeActionBar extends StatelessWidget { @@ -11,9 +13,11 @@ class RecipeActionBar extends StatelessWidget { final int? userRating; final int? rateRemaining; final String? recipeCode; + final int? recipeId; final String? recipeTitle; final String? categoryName; final double? ratingScore; + final RecipeModel? recipe; final void Function(int score) onRate; final VoidCallback onShare; final VoidCallback onNote; @@ -27,9 +31,11 @@ class RecipeActionBar extends StatelessWidget { required this.userRating, this.rateRemaining, this.recipeCode, + this.recipeId, this.recipeTitle, this.categoryName, this.ratingScore, + this.recipe, required this.onRate, required this.onShare, required this.onNote, @@ -76,10 +82,12 @@ class RecipeActionBar extends StatelessWidget { context, title: recipeTitle ?? '菜谱', code: recipeCode, + recipeId: recipeId, categoryName: categoryName, viewCount: likeCount, likeCount: likeCount, ratingScore: ratingScore, + recipe: recipe, ), ), const SizedBox(width: DesignTokens.space2), diff --git a/lib/src/widgets/recipe_detail/interaction/recipe_qr_poster.dart b/lib/src/widgets/recipe_detail/interaction/recipe_qr_poster.dart index be34441..83954ba 100644 --- a/lib/src/widgets/recipe_detail/interaction/recipe_qr_poster.dart +++ b/lib/src/widgets/recipe_detail/interaction/recipe_qr_poster.dart @@ -3,7 +3,7 @@ * 名称: 菜谱二维码海报组件 * 作用: 生成菜谱二维码分享海报,含菜谱信息+二维码+分享链接 * 创建: 2026-04-13 - * 更新: 2026-04-13 初始创建 + * 更新: 2026-04-18 更新路径为 /kitchen/recipe_share.php */ import 'dart:ui' as ui; @@ -11,10 +11,14 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/rendering.dart'; import 'package:qr/qr.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/config/api_config.dart'; +import 'package:mom_kitchen/src/models/recipe/recipe_model.dart'; +import 'package:mom_kitchen/src/services/data/recipe_share_service.dart'; class RecipeQrPoster extends StatelessWidget { final String title; final String? code; + final int? recipeId; final String? categoryName; final String? coverUrl; final int? viewCount; @@ -25,6 +29,7 @@ class RecipeQrPoster extends StatelessWidget { super.key, required this.title, this.code, + this.recipeId, this.categoryName, this.coverUrl, this.viewCount, @@ -32,8 +37,15 @@ class RecipeQrPoster extends StatelessWidget { this.ratingScore, }); - String get _shareUrl => - code != null ? 'https://eat.wktyl.com/recipe/$code' : ''; + String get _shareUrl { + if (code != null && code!.isNotEmpty) { + return '${ApiConfig.baseUrl}/kitchen/recipe_share.php?code=${Uri.encodeComponent(code!)}'; + } + if (recipeId != null) { + return '${ApiConfig.baseUrl}/kitchen/recipe_share.php?id=$recipeId'; + } + return ''; + } @override Widget build(BuildContext context) { @@ -258,6 +270,120 @@ void showQrPosterSheet( BuildContext context, { required String title, String? code, + int? recipeId, + String? categoryName, + int? viewCount, + int? likeCount, + double? ratingScore, + RecipeModel? recipe, +}) { + // 先推送数据到服务器 + if (recipe != null) { + _pushAndShow(context, recipe); + } else { + _showPosterOnly( + context, + title: title, + code: code, + recipeId: recipeId, + categoryName: categoryName, + viewCount: viewCount, + likeCount: likeCount, + ratingScore: ratingScore, + ); + } +} + +/// 推送数据并显示海报 +Future _pushAndShow(BuildContext context, RecipeModel recipe) async { + // 显示加载提示 + showCupertinoModalPopup( + context: context, + builder: (ctx) { + final isDark = CupertinoTheme.brightnessOf(ctx) == Brightness.dark; + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.background : DesignTokens.background, + borderRadius: DesignTokens.borderRadiusLg, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CupertinoActivityIndicator(radius: 16), + const SizedBox(height: DesignTokens.space2), + Text( + '正在生成分享海报...', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ); + }, + ); + + try { + // 推送数据到服务器 + final shareUrl = await RecipeShareService().pushRecipeToShare(recipe); + + // 关闭加载提示 + if (context.mounted) { + Navigator.pop(context); + } + + if (shareUrl != null && context.mounted) { + // 显示海报 + _showPosterOnly( + context, + title: recipe.title, + code: recipe.code, + recipeId: recipe.id, + categoryName: recipe.categoryName, + viewCount: recipe.statistics?.views, + likeCount: recipe.statistics?.likes, + ratingScore: recipe.rating?.score, + ); + } else if (context.mounted) { + // 推送失败,仍然显示海报(使用本地数据) + _showPosterOnly( + context, + title: recipe.title, + code: recipe.code, + recipeId: recipe.id, + categoryName: recipe.categoryName, + viewCount: recipe.statistics?.views, + likeCount: recipe.statistics?.likes, + ratingScore: recipe.rating?.score, + ); + } + } catch (e) { + debugPrint('❌ 推送分享数据异常: $e'); + if (context.mounted) { + Navigator.pop(context); + // 仍然显示海报 + _showPosterOnly( + context, + title: recipe.title, + code: recipe.code, + recipeId: recipe.id, + categoryName: recipe.categoryName, + viewCount: recipe.statistics?.views, + likeCount: recipe.statistics?.likes, + ratingScore: recipe.rating?.score, + ); + } + } +} + +/// 仅显示海报(不推送数据) +void _showPosterOnly( + BuildContext context, { + required String title, + String? code, + int? recipeId, String? categoryName, int? viewCount, int? likeCount, @@ -304,6 +430,7 @@ void showQrPosterSheet( RecipeQrPoster( title: title, code: code, + recipeId: recipeId, categoryName: categoryName, viewCount: viewCount, likeCount: likeCount, diff --git a/pubspec.yaml b/pubspec.yaml index ec35550..2988e8d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -197,6 +197,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - assets/photos/ + - assets/icons/ - assets/json/ - assets/md/tips/ - assets/md/tips/advanced/