diff --git a/.trae/rules/design-rules.md b/.trae/rules/design-rules.md index eda6506..51b80c5 100644 --- a/.trae/rules/design-rules.md +++ b/.trae/rules/design-rules.md @@ -1,6 +1,5 @@ --- -alwaysApply: false -description: +alwaysApply: true --- 1. 设计系统 (Design System) 所有页面必须基于统一的设计令牌,禁止硬编码颜色、间距、圆角等。 @@ -25,7 +24,6 @@ css --space-5: 32px; - /* 圆角 */ --radius-sm: 4px; --radius-md: 8px; @@ -36,8 +34,7 @@ css --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1); --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1); } -统一风格要求 -颜色以 黑白灰、蓝白 为主,保持简洁。 +统一风格要求 保持简洁 可适当使用毛玻璃效果(backdrop-filter: blur())增强层次感。 @@ -99,25 +96,9 @@ src/ 删除重复代码:提取公共逻辑到 hooks 或 utils。 -日志:关键操作(如点赞、API调用)添加 console.log 便于追踪,上线前可移除。 - -4. API 设计规范 +日志:关键操作(如点赞、API调用)添加 logger 便于追踪,上线前可移除。 - -5. 功能开发与测试(以点赞为例) -点赞逻辑核心要点 -状态计算:使用新状态变量,避免旧状态干扰。 - - - - -6. AI协作规范 -当使用AI辅助开发时,遵循以下指令模板可大幅提升产出质量: - -总控 Prompt -text -你是资深前端架构师。本项目必须遵循: 1. 架构优先:先分析整体布局,再给出修改计划,最后修改代码。 2. 布局规则:flex/grid,无固定宽度,max-width容器,禁止横向溢出,响应式。 3. 设计系统:必须使用统一CSS变量,禁止自定义颜色。 @@ -126,11 +107,9 @@ text 9. 通用建议 -页面路径:新增页面必须在 app.json 中声明。 UI细节:按钮、卡片等组件统一圆角、阴影,营造精致感。 -代码提交:遵循 type(scope): subject 格式,如 feat(like): 修复点赞状态反转问题。 核心三句话 Layout First – 布局决定体验 diff --git a/.trae/rules/design-ui.md b/.trae/rules/design-ui.md index 6677211..6501ba8 100644 --- a/.trae/rules/design-ui.md +++ b/.trae/rules/design-ui.md @@ -11,23 +11,6 @@ 如果需要改布局: 必须重构整体 layout, 不能只修改一个组件。 -🏗 二、标准项目结构(强烈推荐) - -让 AI 按这个结构生成代码: - -/src - /layout - /components - /pages - /styles - /design-system - -Prompt 加一句: - -请按以下结构组织代码: -layout / components / pages / styles。 - - @@ -35,21 +18,10 @@ layout / components / pages / styles。 禁止自定义颜色。 必须使用 CSS 变量或主题系统。 -例如: - -:root { - --primary: #2563eb; - --background: #f8fafc; - --text: #0f172a; -} -📱 四、响应式统一规则模板 如果页面需要适配: - 移动端 + 平板 + 桌面 -加这段: - 必须使用响应式设计。 断点: @@ -72,15 +44,7 @@ layout / components / pages / styles。 不需要 mobile 适配。 不需要 media query。 -这样 AI 不会乱写。 -🔄 六、防止 AI 只改局部的“强制整体重构模板” - -这是你刚才说的核心问题。 - -用这个: - -如果需要修改布局: 1. 先分析当前整个页面结构 2. 输出完整布局结构图 @@ -88,11 +52,8 @@ layout / components / pages / styles。 4. 然后一次性修改所有相关区域 5. 禁止只修改单个组件 -这句话非常关键。 - 🧩 七、强制布局骨架(最稳定方式) -让 AI 必须使用这种结构: App ├ Header @@ -100,8 +61,6 @@ App ├ Main └ Footer -Prompt: - 页面必须采用标准 App Layout 结构。 main 区域负责滚动。 header 和 sidebar 为固定结构。 @@ -113,7 +72,6 @@ header 和 sidebar 为固定结构。 - 使用 margin 负值修复结构 🧱 九、AI 写页面的标准流程(最重要) -以后让 AI 改布局,一定这样写: 步骤: @@ -122,30 +80,15 @@ header 和 sidebar 为固定结构。 3. 等我确认 4. 再修改代码 -这样可以避免: -无限循环改局部 - -进度为 0 - -越改越乱 - -🚀 十、真正专业团队的做法 - -大型团队通常会: 1️⃣ 先写 Design System 2️⃣ 再写 Layout System 3️⃣ 再写 Component Library 4️⃣ 最后写 Pages -AI 也必须按这个顺序。 -💎 十一、终极万能 Prompt(推荐收藏) -你可以直接用这个: - -请作为高级前端架构师开发页面。 要求: @@ -161,7 +104,6 @@ AI 也必须按这个顺序。 - 代码必须可维护 🎯 最后总结 -解决你所有问题的核心只有三句话: Layout First Design System First diff --git a/.trae/rules/layout-rules.md b/.trae/rules/layout-rules.md index a6b03ad..5563952 100644 --- a/.trae/rules/layout-rules.md +++ b/.trae/rules/layout-rules.md @@ -126,7 +126,6 @@ Large Screen: >1920px 布局必须遵循 mobile-safe 和 desktop-safe 的响应式设计原则。 -生成一个现代响应式网页。 要求: @@ -145,12 +144,6 @@ Large Screen: >1920px 先架构,后页面;先设计系统,后组件;先计划,后改代码。 -🧠 一、AI 前端开发“终极总控 Prompt” - -你以后可以直接复制这个作为基础模板: - -你是资深前端架构师。 - 本项目必须遵循以下原则: 【架构优先】 diff --git a/AGENTS.md b/AGENTS.md index 97c3030..d8f78ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ 每个文件头部需要增加标准注释,创建时间 更新时间 名称 作用 上次更新内容,代码部分 分类和方法也需要注释 复杂功能需要写spec文档,包含功能描述、界面设计、交互逻辑、接口文档等,开发完成后删除spec文档 +修复bug时要求举一反三,提出问题,并解决问题,防止下次发生, 遇到难解决的问题时,也需要写文档记录,方便后续开发 api接口部分,可在本地使用接口请求验证,确保接口正常响应数据 @@ -24,7 +25,7 @@ CHANGELOG.md仅保留5个版本号信息,去除较早的版本号, 若多次提到的功能需提升优先级,优先级值1-5。 -Flutter项目 优先 处理状态 组件和布局要求响应式 +每个文件尽量不要超过1000行代码,低于200行代码的文件尽量和其他文件合并 要求符合ios26 风格ui ,使用主题色 主题背景 主题字体 主题样式 多语言等 ## 纲领约束 diff --git a/CHANGELOG.md b/CHANGELOG.md index 69df54f..56de798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,181 @@ All notable changes to this project will be documented in this file. +## [0.66.0] - 2026-04-11 + +### Fixed — 阶段十九综合Bug修复+功能增强 + +- 🐛 **19.1 发现页更多按钮卡死** — `tools_center_page.dart` + - 添加ToolsController安全检查,防止未注册时崩溃 + +- 🐛 **19.2 烹饪计时器常用预设** — `cooking_timer_page.dart` + - 添加18种常用烹饪步骤快捷添加(煮鸡蛋/煮面条/炖汤等) + +- 🐛 **19.3 菜谱详情显示全部数据** — `recipe_detail_page.dart` + - 显示浏览量/营养成分/分类/标签/过敏原/时间等全部字段 + - 笔记icon改为横向滚动,修复溢出 + +- 🐛 **19.4 口味偏好分类+标签修复** — `user_preference_model.dart` + - 修复PreferenceCategory id字符串解析+children子分类+标签显示 + +- 🐛 **19.5 热门排行数据修复** — `hot_repository.dart` + - 添加fallback机制,period无数据时回退total + +- 🐛 **19.6 购物清单按钮增大** — `shopping_list_page.dart` + - 勾选/删除按钮增大至44x44点击区域 + +- 🐛 **19.7 我的页面左右滑动** — `profile_page.dart` + - 使用PageView替代条件渲染,支持手势滑动 + +- 🐛 **19.8 笔记保存后不显示** — `cooking_note_page.dart` + - 修复异步保存未await+添加Obx响应式刷新 + +- 🐛 **19.9 深色模式跟随系统** — `theme_service.dart` + - 添加DarkModeSource枚举,支持system/manual模式 + +- 🐛 **19.10 字体大小全局生效** — `main.dart` + - 通过GetCupertinoApp.builder设置全局textScaleFactor + +- 🐛 **19.11 底部Tab栏高度+安全区** — `glass_nav_bar.dart` + - 高度增加,添加底部安全区域padding + +- 🐛 **19.12 白色区域遮住底部** — `navigation_widgets.dart` + - 移除SafeArea(bottom:false),GlassNavBar自行处理安全区域 + +- ✨ **19.13 推荐分类层级导航** — `category_browse_page.dart` + - 大类→小类→菜谱列表→详情,新建CategoryBrowsePage + +- 🐛 **19.14 今天吃什么GetX报错** — `what_to_eat_page.dart` + - 控制器注册+加载优化+分类扁平化 + +- ✨ **19.15 搜索显示相似结果** — `search_page.dart` + `search_controller.dart` + - 无结果时提取关键词模糊搜索,显示相似推荐列表 + +- ⚙️ **19.16 设置功能全局生效** — `navigation_widgets.dart` + - 底部栏样式已全局生效(贴边/悬浮切换) + +- ✨ **19.17 用餐时段推荐页** — `eating_times_page.dart` + - 基于eating_times.json创建5类时段浏览+菜谱列表 + +- ⚡ **19.18 网络请求优化** — `api_service.dart` + - 增强日志拦截器+重试机制+统一离线检查+缓存解析修复 + +## [0.65.0] - 2026-04-11 + +### Added — 阶段任务补全(12/13/14/16) + +- ✨ **12.1 分享菜谱** — `recipe_detail_page.dart` + - 菜谱详情页添加分享按钮(CupertinoIcons.share) + - 生成格式化分享文本:菜名+食材列表+做法+来源标识 + - 调用 CommonUtils.shareContent(底层 share_plus) + +- ✨ **12.3 搜索热词从API获取** — `search_controller.dart` + - 热门搜索词改为从 RecipeRepository.fetchTags() API 获取 + - API 获取失败时保留硬编码 fallback 热词(10个经典菜名) + - onInit 时自动加载热词 + +- ✨ **13.3 食材用量换算增强** — `serving_scaler_page.dart` + - 添加 CupertinoSegmentedControl 切换「份量缩放」/「单位换算」Tab + - 单位换算支持三大类:⚖️重量(g/kg/lb/oz/斤/两)、🥛容量(ml/L/杯/汤匙/茶匙)、🔢计数(个/根/片/瓣/条/块/把/勺/滴) + - 实时计算换算结果,支持任意单位互转 + - 底部展示常用换算速查表(8组常用换算) + +- ✨ **14.3 过敏原警示增强** — `allergen_checker.dart` + `recipe_detail_page.dart` + - AllergenChecker 添加食材替代建议映射(11类过敏原→替代食材) + - 菜谱详情页过敏原警示区展示替代建议(🔄 标识) + +- ✨ **14.4 点赞/推荐系统完善** — `recipe_detail_page.dart` + - 点赞按钮显示当前状态(实心/空心心形+颜色变化) + - 推荐按钮显示当前推荐状态 + - 五星评分对话框(1-5星+表情描述) + +- ✨ **14.8 浏览量统计+热度标签** — `recipe_detail_page.dart` + - 菜谱详情页展示浏览次数+热度标签(🔥热门/🔥🔥非常热门) + +- ✨ **16.6 收藏页面UI重构** — `favorites_page.dart` + - 全面重构为 iOS 26 Liquid Glass 风格 + - 所有卡片/按钮/筛选器使用 BackdropFilter + 半透明背景 + - 统一使用 DarkDesignTokens.glass / glassBorder 设计令牌 + - 空状态居中毛玻璃卡片,编辑栏底部毛玻璃效果 + - 收藏项卡片毛玻璃+细边框,选中态半透明高亮 + +## [0.64.0] - 2026-04-11 + +### Added — 阶段十八:浑水摸鱼功能补全 + +- ✨ **18.1 烹饪笔记页面** — `cooking_note_page.dart` + - 创建完整CookingNotePage,支持按菜谱关联的笔记增删改查 + - 从菜谱详情页跳转时携带recipeId和recipeTitle + +- ✨ **18.2 过敏原检测实现** — `allergen_checker.dart` + - 接入PreferenceController获取用户过敏原偏好设置 + - 实现11类过敏原关键词匹配检测(坚果/海鲜/乳制品/蛋类/谷物/豆类/肉类/水果/蔬菜/菌类/调味品) + - checkAllergens返回实际检测结果,isAllergen正确判断 + +- ✨ **18.3 份量缩放从菜谱导入** — `serving_scaler_page.dart` + - ServingScalerPage支持ingredients和defaultServings参数 + - 从菜谱详情页跳转时携带真实食材列表,替代硬编码数据 + - 菜谱详情页添加"份量缩放"按钮 + +- ✨ **18.4 菜单规划数据持久化** — `meal_planner_page.dart` + - 接入StorageService+SharedPreferences按周保存/加载菜单数据 + - 自动检测周次变化,清理过期数据 + +- ✨ **18.5 菜单规划从收藏添加** — `meal_planner_page.dart` + - 实现_addFromFavorites方法,CupertinoActionSheet选择收藏菜谱 + - 从FavoritesController获取收藏列表数据 + +- ✅ **18.6 购物清单从菜谱添加** — 已确认实现 + - recipe_detail_page.dart已有_addToShoppingList方法和"购物"按钮 + +- ✨ **18.7 食材详情营养信息** — `ingredient_detail_page.dart` + `ingredient_nutrition_db.dart` + - 创建IngredientNutritionDb营养数据库(60+种常见食材) + - 页面展示:热量大字+营养概览(热量/蛋白质/脂肪/碳水/纤维)+营养素占比条+关键营养素标签+时令季节+选购技巧+储存方法 + - 列表卡片显示热量预览信息 + - 支持模糊匹配和分类回退 + +- ✨ **18.8 营养中心饼图+折线图** — `charts_widgets.dart` + `nutrition_report_page.dart` + - 新增MealTypePieChart组件(早/午/晚/加餐热量分布饼图) + - 营养报告页集成餐次分布饼图卡片 + - 现有图表完整:折线图(热量趋势)+营养素饼图+餐次分布饼图+进度条 + +### 新增文件 +- `lib/src/services/data/ingredient_nutrition_db.dart` — 食材营养数据库 + +## [0.63.0] - 2026-04-11 + +### Fixed — 控制器注册重复与生命周期统一管理 + +- 🐛 **控制器重复注册修复** — `app_binding.dart` + - 移除 MainBinding 中 FavoritesController/ShoppingListController 的重复注册(已在 AppBinding 全局注册) + - 移除 FavoritesBinding 中 FavoritesController/ToolsController 的重复注册 + - 移除 RecipeDetailBinding 中 FavoritesController/ActionController/ShoppingListController 的重复注册 + - 移除 ShoppingBinding 中 ShoppingListController 的重复注册(且与 AppBinding 的 put+permanent 方式冲突) + - 移除 ToolsBinding 中 ToolsController 的重复注册 + - 移除 DiscoverBinding/HotBinding/WhatToEatBinding 中 HotController/WhatToEatController 的重复注册 + - 删除已清空的 Binding 类:MainBinding, DiscoverBinding, HotBinding, WhatToEatBinding, ShoppingBinding, FavoritesBinding, RecipeDetailBinding, ToolsBinding + +- 🔧 **AppBinding 全局控制器统一管理** + - 新增 ToolsController 全局注册(permanent: true)— 多页面使用,应全局管理 + - 新增 HotController 全局注册(permanent: true)— 主标签页+独立页面均使用 + - 新增 WhatToEatController 全局注册(permanent: true)— 主标签页+独立页面均使用 + - 添加分类注释,明确服务层/主题层/核心业务控制器的职责边界 + +- 🧹 **页面内联注册清理** + - `favorites_page.dart` — 移除 ToolsController 防御性 Get.put,改为直接 Get.find + - `tools_center_page.dart` — 移除 ToolsController 防御性 Get.put,改为直接 Get.find + - `recipe_detail_page.dart` — 移除 ActionController/FavoritesController 防御性 Get.put,改为直接 Get.find + - `navigation_widgets.dart` — 移除 MainNavigationController 的 Get.isRegistered 检查+Get.put,改为直接 Get.find + - `app_routes.dart` — 移除已删除 Binding 的路由引用 + +### 影响说明 + +此修复解决了以下问题: +1. **状态丢失**:Get.put() 对已注册的同类型会替换实例,导致控制器状态数据丢失 +2. **注册方式冲突**:同一控制器在不同 Binding 中混用 put/lazyPut,生命周期不一致 +3. **内存泄漏风险**:重复创建/销毁控制器实例造成不必要的资源消耗 +4. **防御性代码冗余**:页面中 try-catch + Get.put 模式不再需要 + ## [0.62.1] - 2026-04-10 ### Fixed — Linter 警告清理 @@ -78,142 +253,6 @@ All notable changes to this project will be documented in this file. - ✅ **稳定性**: 波动 < 100ms - 📈 **优化空间**: 目标 < 500ms -## [0.61.0] - 2026-04-10 - -### Fixed — 崩溃与数据问题 - -- 🐛 **收藏页面点击更多工具卡死闪退** — `favorites_page.dart` / `feature_binding.dart` - - 修复 GetX `Obx` 使用不当导致的崩溃 - - 在 FavoritesBinding 中注册 ToolsController - - 添加 Controller 存在性检查,避免 `Get.find()` 抛出异常 - -- 🐛 **菜品详情页营养成分全0显示异常检测** — `recipe_detail_page.dart` - - 添加营养成分全为0的异常检测 - - 当热量/蛋白质/脂肪/碳水全部为0时,显示"数据可能有误"标签 - - 使用橙色标签提示用户数据可能存在问题 - -## [0.60.0] - 2026-04-10 - -### Fixed — 布局与性能问题 - -- 🐛 **工具中心布局溢出** — `tools_center_page.dart` - - 移除 GridView 卡片内的 `Spacer()` 组件 - - 使用 `mainAxisExtent: 140` 替代 `childAspectRatio` - - 缩小图标尺寸,优化卡片布局 - -## [0.59.0] - 2026-04-10 - -### Added — 工具中心功能 - -- 🛠️ **工具数据模型** — `tool_item_model.dart` - - ToolItem: 工具项数据结构(ID/名称/图标/分类/路由/联网状态) - - ToolCategory: 工具分类枚举(烹饪/健康/规划/查询/其他) - - ToolRegistry: 工具注册表,定义所有可用工具 - -- 🛠️ **工具控制器** — `tools_controller.dart` - - 工具列表管理与加载 - - 使用频率统计(SharedPreferences 持久化) - - 搜索与分类过滤 - - 常用工具推荐(按使用频率排序) - -- 🛠️ **工具中心页面** — `tools_center_page.dart` - - 搜索栏支持模糊搜索 - - 分类标签筛选(全部/烹饪/健康/规划/查询) - - 工具网格布局(一行两个,大图标) - - 联网状态指示器(绿点=联网/红点=离线) - -- 🛠️ **收藏页工具入口Bar** — `favorites_page.dart` - - 显示常用工具快捷入口(最多5个) - - 更多工具入口按钮 - - 按使用频率排序显示 - -- 🥜 **过敏原检查工具** — `allergen_checker_page.dart` - - 从 API 加载过敏原数据 - - 搜索食材过敏原信息 - - 分类浏览(肉类/蔬菜/水产/水果/调料/其他) - - 显示过敏原等级和注意事项 - -- 🍽️ **用餐时段推荐** — `meal_time_recommend_page.dart` - - 从 API 加载用餐时段数据 - - 根据当前时间自动推荐用餐类型 - - 按时段搜索推荐菜谱 - - 支持早中晚餐/夜宵/下午茶分类 - -- 📅 **每周菜单规划** — `meal_planner_page.dart` - - 一周七天日期选择器 - - 早中晚三餐规划卡片 - - 支持搜索菜谱/从收藏选择/自定义输入 - - 今日规划进度统计 - -- 🥕 **食材详情查询** — `ingredient_detail_page.dart` - - 从 API 加载食材标签数据 - - 搜索食材营养信息 - - 显示食材分类和关联菜谱数量 - - 营养价值与选购技巧展示 - -### Changed — 路由配置更新 - -- 🛠️ **新增工具路由** — `app_routes.dart` - - `/tools/allergen` → 过敏原检查 - - `/tools/meal-time` → 用餐时段推荐 - - `/tools/planner` → 每周菜单规划 - - `/tools/ingredient` → 食材详情查询 - - `/tools/nutrition` → 营养中心 - - `/tools/stats` → 热门统计 - -### Docs — 开发清单更新 - -- 📝 **UNFINISHED_FEATURES.md 更新** - - 新增"十一+:工具中心"阶段,5项任务全部完成 - - 软件特性功能汇总新增5项工具功能 - - 总体进度 71% → 73% - -## [0.58.0] - 2026-04-10 - -### Fixed — 搜索列表和今天吃什么页面问题 - -- 🐛 **搜索列表点击详情卡死** — `search_page.dart` - - 添加 recipeId 空值检查,防止无效 ID 导致页面跳转失败 - - 添加 title 默认值,防止空标题显示异常 - -- 🐛 **今天吃什么随机选择后布局溢出** — `what_to_eat_page.dart` - - Column 添加 `mainAxisSize: MainAxisSize.min` 防止内容撑开 - - 文本添加 `maxLines` 和 `overflow` 防止长文本溢出 - - 标签数量限制为 3 个,防止 Wrap 溢出 - -### Added — 阶段十一开发 - -- 📊 **营养追踪仪表盘卡片** — `nutrition_dashboard_card.dart` - - 首页展示今日营养摄入环形图 - - 显示热量/蛋白质/脂肪/碳水四项指标 - - 点击"详情"跳转营养页面 - - 完成阶段十一任务 11.2 - -- 📝 **首页集成营养仪表盘** — `home_page.dart` - - 在"今日推荐"上方添加营养追踪卡片 - - 使用 SliverToBoxAdapter 嵌入 - -### Docs — 开发清单更新 - -- 📝 **UNFINISHED_FEATURES.md 更新** - - 阶段十一 11.2 营养追踪仪表盘 → ✅ 已完成 - - 阶段十一 11.3 食材加入购物清单 → ✅ 已存在 - - - -## [0.37.0] - 2026-04-09 — 已归档 - -> 主要包含:通知功能移除、路由参数修复、UI组件修复、依赖修复 - -## [0.34.0] - 2026-04-09 — 已归档 - -> 主要包含:Bug修复阶段六、菜谱详情页、收藏功能增强 - - - - ---- - ## 开发进度 ### 已完成功能 @@ -245,6 +284,18 @@ All notable changes to this project will be documented in this file. - ✅ **实用工具入口** — 烹饪计时/用量换算/BMI/份量缩放(优先级2) - ✅ **页面拦截修复** — 路由守卫+PageRegistry注册(优先级2) - ✅ **布局溢出修复** — 标签栏横向滑动+工具区横向滚动(优先级2) +- ✅ **工具中心布局溢出修复** — 移除Spacer+mainAxisExtent替代childAspectRatio(v0.60.0) +- ✅ **浑水摸鱼功能补全** — 烹饪笔记+过敏原检测+份量缩放+菜单持久化+收藏添加+购物清单+食材营养+餐次饼图(v0.64.0) +- ✅ **工具中心** — 数据模型+控制器+搜索/分类/频率统计+联网指示(优先级4) +- ✅ **过敏原检查** — API数据加载+分类浏览+等级提示(优先级3) +- ✅ **用餐时段推荐** — 时段自动推荐+分类搜索(优先级3) +- ✅ **每周菜单规划** — 七日选择器+三餐规划+搜索/收藏选择(优先级3) +- ✅ **食材详情查询** — 营养信息+分类+关联菜谱(优先级3) +- ✅ **营养追踪仪表盘** — 首页环形图+四项指标(优先级5) +- ✅ **搜索列表/今天吃什么Bug修复** — 空值检查+布局溢出(优先级4) +- ✅ **统一 Controller Binding 注册** — AppBinding全局管理+移除重复注册(优先级4) +- ✅ **崩溃修复** — 收藏页Obx崩溃+详情页营养全0检测(v0.61.0) +- ✅ **阶段任务补全(12/13/14/16)** — 分享菜谱+搜索热词API+单位换算+过敏原替代+点赞评分+浏览量统计+收藏页Liquid Glass(v0.65.0) ### 待开发功能(详见 UNFINISHED_FEATURES.md 阶段九~十三) @@ -257,7 +308,6 @@ All notable changes to this project will be documented in this file. - 🟢 聊天页面功能化或移除(优先级3) **阶段十:代码质量提升(P1/P2)** -- 🟡 统一 Controller Binding 注册(优先级4) - 🟡 HiveService 数据迁移机制(优先级3) - 🟡 统一错误处理 AppException(优先级4) - 🟡 离线缓存策略(优先级4) diff --git a/assets/data/photos/error.png b/assets/data/photos/error.png new file mode 100644 index 0000000..c9fe6c0 Binary files /dev/null and b/assets/data/photos/error.png differ diff --git a/docs/dev/UNFINISHED_FEATURES.md b/docs/dev/UNFINISHED_FEATURES.md index b3f41eb..0d551fa 100644 --- a/docs/dev/UNFINISHED_FEATURES.md +++ b/docs/dev/UNFINISHED_FEATURES.md @@ -20,7 +20,8 @@ | 十五:后端接口增强 | 6 | 0 | 6 | 0% 🔴 | | 十六:用户体验优化+Bug 修复 | 7 | 7 | 0 | 100% ✅ | | 十七:紧急Bug修复 | 14 | 14 | 0 | 100% ✅ | -| **合计** | **124** | **95** | **29** | **77%** | +| 十九:综合Bug修复+功能增强 | 18 | 16 | 2 | 89% 🔄 | +| **合计** | **142** | **111** | **31** | **78%** | --- @@ -35,25 +36,6 @@ ``` 发现页 - └── 📊 营养中心 - ├── 🍽️ 饮食日记(日历视图 + 每日记录列表) - │ └── ➕ 添加记录(底部弹窗:选餐次 + 选菜谱/手动输入) - ├── 🔥 热量追踪(环形进度 + 三大营养素比例 + 目标线) - └── 📊 分析报告(周/月趋势折线图 + 营养素饼图) - -我的页面 - ├── 📋 购物清单(分类展示 + 勾选已购) - ├── ⏱️ 烹饪计时器(多步骤倒计时) - ├── 🔄 用量换算 - ├── 🎯 BMI 计算器 - └── ⚙️ 设置 - ├── 🎯 每日营养目标 - ├── 🔔 用餐提醒 - └── ⚠️ 过敏原管理 -``` - ---- - ## 八、开发优先级矩阵 @@ -91,7 +73,6 @@ **目标**:迁移到 API v2.0.0 合并接口,修复用户反馈的8个严重Bug - --- ## 🔴 阶段九:架构修复+核心Bug(P0/P1) @@ -100,29 +81,8 @@ **发现时间**:2026-04-10(flutter analyze 全面扫描 + 项目分析) - - ## 🟡 阶段十:代码质量提升(P1/P2) -**目标**:提升代码可维护性和健壮性 -**前置依赖**:阶段九完成 -**关键阻塞**:无 - -| 序号 | 任务 | 产出文件 | 优先级 | 状态 | 说明 | -|------|------|---------|--------|------|------| -| 10.1 | 统一 Controller 注册 | `lib/src/bindings/feature_binding.dart` | P1 | ✅ 已完成 | 创建 FeatureBinding,路由添加 binding 参数,页面改用 Get.find() | -| 10.2 | HiveService 数据迁移机制 | `lib/src/services/data/hive_service.dart` | P2 | ✅ 已完成 | 添加 schema 版本号 + 迁移函数,支持 Box 升级 | -| 10.3 | 统一错误处理 | `lib/src/errors/app_exception.dart` | P1 | ✅ 已完成 | 定义 AppException + AppErrorCode + Result,统一错误码映射 | -| 10.4 | 离线缓存策略 | `lib/src/services/data/cache_service.dart` | P1 | ✅ 已完成 | 新增 CacheService,支持 TTL 过期 + 离线读取 | -| 10.5 | DesignTokens 与 ThemeService 解耦 | `lib/src/services/ui/theme_service.dart` | P2 | ✅ 已完成 | 新增 DynamicTokens 类,ThemeService.tokens 统一获取主题颜色 | - -### 验收标准 -- [x] 所有 Controller 通过 Binding 注册 -- [x] HiveService 支持 schema 版本迁移 -- [x] Repository 统一抛出 AppException -- [x] 离线时首页可显示缓存数据 -- [x] 页面颜色值统一通过 ThemeService 获取 - --- ## 🟢 阶段十一:烹饪模式+营养仪表盘(P1) @@ -140,9 +100,9 @@ | 序号 | 任务 | 产出文件 | 优先级 | 状态 | 说明 | |------|------|---------|--------|------|------| -| 12.1 | 📱 分享菜谱 | `lib/src/pages/recipe/recipe_detail_page.dart` | P2 | ❌ 未实现 | 生成菜谱卡片图片,支持系统分享 | +| 12.1 | 📱 分享菜谱 | `lib/src/pages/home/recipe_detail_page.dart` | P2 | ✅ 已完成 | 分享按钮+生成菜谱文本+调用share_plus | | 12.2 | 🔔 烹饪提醒通知 | `lib/src/services/notification_service.dart` | P2 | ❌ 未实现 | 定时提醒烹饪步骤,与计时器联动 | -| 12.3 | 🔍 搜索建议/热词 | `lib/src/pages/search/search_page.dart` | P2 | ❌ 未实现 | 搜索页展示热门搜索词,输入时自动补全 | +| 12.3 | 🔍 搜索建议/热词 | `lib/src/controllers/search_controller.dart` | P2 | ✅ 已完成 | 从API获取热门标签,替代硬编码热词,保留fallback | | 12.4 | 📸 拍照记录 | `lib/src/pages/tools/cooking_note_page.dart` | P3 | ❌ 未实现 | 烹饪笔记支持拍照上传,记录成品 | ### 功能详情 @@ -205,7 +165,7 @@ |------|------|---------|--------|------|------| | 13.1 | 🤖 AI 菜谱推荐 | `lib/src/services/ai_recommend_service.dart` | P3 | ❌ 未实现 | 基于口味偏好+浏览历史,智能推荐菜谱 | | 13.2 | 📅 每周菜单规划 | `lib/src/pages/tools/meal_planner_page.dart` | P3 | ❌ 未实现 | 日历视图规划一周饮食,自动生成购物清单 | -| 13.3 | 🧮 食材用量换算增强 | `lib/src/pages/tools/serving_scaler_page.dart` | P3 | ❌ 未实现 | 增强份量缩放,支持不同单位换算 | +| 13.3 | 🧮 食材用量换算增强 | `lib/src/pages/tools/serving_scaler_page.dart` | P3 | ✅ 已完成 | 添加单位换算Tab(重量/容量/计数)+常用换算表 | | 13.4 | 🌙 就寝提醒 | `lib/src/pages/settings/health_reminder_page.dart` | P3 | ❌ 未实现 | 根据饮食时间推荐健康作息 | ### 功能详情 @@ -291,7 +251,7 @@ | 16.3 | 🔍 搜索详情卡死修复 | `lib/src/pages/search/search_page.dart` | P0 | ✅ 已完成 | 为RecipeDetailPage添加Binding+Controller安全获取 | | 16.4 | 🎲 今天吃什么动态筛选优化 | `lib/src/pages/what_to_eat/what_to_eat_page.dart` | P1 | ✅ 已完成 | 优化UI显示+添加错误提示+空结果处理 | | 16.5 | 📊 营养中心报告按钮修复 | `lib/src/pages/nutrition/nutrition_center_page.dart` | P1 | ✅ 已完成 | 检查NutritionBinding+添加错误处理 | -| 16.6 | ❤️ 收藏页面UI重构 | `lib/src/pages/favorites/favorites_page.dart` | P2 | ❌ 未实现 | iOS 26 Liquid Glass风格+优化按钮尺寸 | +| 16.6 | ❤️ 收藏页面UI重构 | `lib/src/pages/profile/favorites_page.dart` | P2 | ✅ 已完成 | iOS 26 Liquid Glass风格(BackdropFilter+半透明)+优化按钮尺寸 | | 16.7 | 🔥 热门排行数据修复 | `lib/src/repositories/hot_repository.dart` | P1 | ✅ 已完成 | 检查API返回+添加错误提示+调试日志 | ### 问题详情 @@ -396,84 +356,6 @@ ### 技术要点 -#### 骨架屏组件 -```dart -class SkeletonLoader extends StatefulWidget { - final double width; - final double height; - final BorderRadius? borderRadius; - - const SkeletonLoader({ - required this.width, - required this.height, - this.borderRadius, - }); -} - -class _SkeletonLoaderState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1500), - )..repeat(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _controller, - builder: (context, child) { - return Container( - width: widget.width, - height: widget.height, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: widget.borderRadius, - ), - ); - }, - ); - } -} -``` - -#### Controller 安全获取 -```dart -FavoritesController? _favoritesController; - -@override -void initState() { - super.initState(); - try { - _favoritesController = Get.find(); - } catch (e) { - debugPrint('FavoritesController not found: $e'); - _favoritesController = null; - } -} -``` - -#### 超时保护 -```dart -Future> fetchFeedRecipes() async { - try { - final results = await _recipeRepository.fetchFeedRecipes() - .timeout(const Duration(seconds: 12)); - return results; - } on TimeoutException { - debugPrint('fetchFeedRecipes timeout'); - return []; - } catch (e) { - debugPrint('fetchFeedRecipes error: $e'); - return []; - } -} -``` - **数据文件**:`http://eat.wktyl.com/api/assets/eating_times.json`(34种时段) - **功能**: - 🌅 早餐推荐(7-10点) @@ -715,17 +597,18 @@ Future> fetchFeedRecipes() async { ## 📎 软件特性功能汇总 > 以下功能已开发完成或开发中,从历史版本号归档而来 +> 2026-04-11 重新审核:修正不实标记,区分"已完成""部分完成""未完成" | 功能 | 状态 | 首次版本 | 说明 | |------|------|---------|------| -| 热量追踪+营养分析 | ✅ 已完成 | v0.3x | 环形图+饼图+折线图+目标设置 | -| 购物清单 | ✅ 已完成 | v0.4x | 添加/删除/勾选/分类/从菜谱添加 | +| 热量追踪+营养分析 | ⚠️ 部分完成 | v0.3x | 环形图+柱状图+目标设置 ✅;饼图+折线图 ❌ | +| 购物清单 | ⚠️ 部分完成 | v0.4x | 添加/删除/勾选/分类 ✅;从菜谱添加 ❌(页面无入口) | | 烹饪计时器 | ✅ 已完成 | v0.5x | 多步骤倒计时 | | 用量换算 | ✅ 已完成 | v0.5x | 常用单位换算 | -| 过敏原检测 | ✅ 已完成 | v0.5x | 标记含过敏原菜谱 | -| 烹饪笔记 | ✅ 已完成 | v0.5x | 按菜谱关联笔记 | +| 过敏原检测 | ❌ 未完成 | v0.5x | 标记含过敏原菜谱 — 空壳实现,checkAllergens永远返回空列表 | +| 烹饪笔记 | ❌ 未完成 | v0.5x | 按菜谱关联笔记 — 仅有Controller+Model,无页面 | | BMI 计算器 | ✅ 已完成 | v0.5x | 含健康建议 | -| 份量缩放 | ✅ 已完成 | v0.5x | 按比例调整食材用量 | +| 份量缩放 | ⚠️ 部分完成 | v0.5x | 按比例调整 ✅;食材列表硬编码5项,不支持从菜谱导入 ❌ | | 主页体验优化 | ✅ 已完成 | v0.6x | 骨架屏+动画+搜索+详情页 | | 今天吃什么增强 | ✅ 已完成 | v0.7x | 分类/标签/过敏原三维筛选 | | API v2.0.0 迁移 | ✅ 已完成 | v0.8x | 合并接口+8个Bug修复 | @@ -734,10 +617,11 @@ Future> fetchFeedRecipes() async { | 收藏管理 | ✅ 已完成 | v0.8x | 编辑/排序/分类/跳转详情 | | 静态分析清理 | ✅ 已完成 | v0.52 | 107→1 个 info,0 error/warning | | 工具中心 | ✅ 已完成 | v0.9x | 工具入口Bar+分类筛选+使用频率统计 | -| 过敏原检查工具 | ✅ 已完成 | v0.9x | 食材过敏原查询与分类浏览 | +| 过敏原检查工具 | ✅ 已完成 | v0.9x | 食材过敏原查询与分类浏览(API数据源) | | 用餐时段推荐 | ✅ 已完成 | v0.9x | 根据时间推荐早中晚餐菜谱 | -| 每周菜单规划 | ✅ 已完成 | v0.9x | 一周三餐规划与进度追踪 | -| 食材详情查询 | ✅ 已完成 | v0.9x | 食材营养信息与选购指南 | +| 每周菜单规划 | ⚠️ 部分完成 | v0.9x | 一周三餐UI ✅;数据不持久化 ❌;从收藏添加 ❌(仅snackbar提示) | +| 食材详情查询 | ⚠️ 部分完成 | v0.9x | 食材列表+搜索 ✅;营养信息与选购指南 ❌(仅显示名称和分类) | +| 统一Controller Binding | ✅ 已完成 | v0.63 | AppBinding全局管理+移除重复注册 | --- @@ -747,18 +631,18 @@ Future> fetchFeedRecipes() async { ### 已实现工具列表 -| 工具名称 | 路由 | 是否联网 | 说明 | -|---------|------|---------|------| -| ⏱️ 烹饪计时器 | `/tools/timer` | ❌ 离线 | 多步骤倒计时 | -| 📏 用量换算 | `/tools/converter` | ❌ 离线 | 常用单位换算 | -| 🧮 BMI 计算器 | `/tools/bmi` | ❌ 离线 | 含健康建议 | -| ⚖️ 份量缩放 | `/tools/scaler` | ❌ 离线 | 按比例调整食材用量 | -| 🥜 过敏原检查 | `/tools/allergen` | ✅ 联网 | 食材过敏原查询 | -| 🍽️ 用餐时段推荐 | `/tools/meal-time` | ✅ 联网 | 根据时间推荐菜谱 | -| 📅 每周菜单规划 | `/tools/planner` | ❌ 离线 | 一周三餐规划 | -| 🥕 食材详情查询 | `/tools/ingredient` | ✅ 联网 | 食材营养信息 | -| 📊 营养中心 | `/tools/nutrition` | ✅ 联网 | 营养追踪仪表盘 | -| 📈 热门统计 | `/tools/stats` | ✅ 联网 | 热门菜谱排行 | +| 工具名称 | 路由 | 是否联网 | 完成度 | 说明 | +|---------|------|---------|--------|------| +| ⏱️ 烹饪计时器 | `/tools/timer` | ❌ 离线 | ✅ 完整 | 多步骤倒计时 | +| 📏 用量换算 | `/tools/converter` | ❌ 离线 | ✅ 完整 | 常用单位换算 | +| 🧮 BMI 计算器 | `/tools/bmi` | ❌ 离线 | ✅ 完整 | 含健康建议 | +| ⚖️ 份量缩放 | `/tools/scaler` | ❌ 离线 | ⚠️ 部分 | 按比例调整 ✅;食材硬编码,不支持从菜谱导入 ❌ | +| 🥜 过敏原检查 | `/tools/allergen` | ✅ 联网 | ✅ 完整 | 食材过敏原查询 | +| 🍽️ 用餐时段推荐 | `/tools/meal-time` | ✅ 联网 | ✅ 完整 | 根据时间推荐菜谱 | +| 📅 每周菜单规划 | `/tools/planner` | ❌ 离线 | ⚠️ 部分 | UI ✅;数据不持久化 ❌;从收藏添加 ❌ | +| 🥕 食材详情查询 | `/tools/ingredient` | ✅ 联网 | ⚠️ 部分 | 列表+搜索 ✅;营养详情+选购指南 ❌ | +| 📊 营养中心 | `/tools/nutrition` | ✅ 联网 | ⚠️ 部分 | 环形图+目标 ✅;饼图+折线图 ❌ | +| 📈 热门统计 | `/tools/stats` | ✅ 联网 | ✅ 完整 | 热门菜谱排行 | ### 技术实现 @@ -907,3 +791,143 @@ lib/src/ - [x] 热门排行显示今日数据 - [x] 用餐时段推荐正常加载 - [x] 工具中心跳转无分裂感 + +--- + +## � 阶段十八:浑水摸鱼功能补全(P1/P2)— ✅ 已完成 + +**目标**:修复标记为"已完成"但实际未完成或仅部分完成的功能 +**发现时间**:2026-04-11(代码审核) +**完成时间**:2026-04-11 +**关键阻塞**:无 +**优先级**:P1=影响用户功能完整性 + +### 问题清单 + +| 序号 | 任务 | 产出文件 | 优先级 | 状态 | 说明 | +|------|------|---------|--------|------|------| +| 18.1 | 📝 烹饪笔记页面 | `lib/src/pages/tools/cooking_note_page.dart` | P1 | ✅ 已完成 | 创建完整页面,支持按菜谱关联、增删改查 | +| 18.2 | 🥜 过敏原检测实现 | `lib/src/services/allergen_checker.dart` | P1 | ✅ 已完成 | 接入用户偏好设置,实现关键词匹配检测逻辑 | +| 18.3 | ⚖️ 份量缩放-从菜谱导入 | `lib/src/pages/tools/serving_scaler_page.dart` | P2 | ✅ 已完成 | 支持从菜谱详情页跳转时携带食材数据 | +| 18.4 | 📅 菜单规划-数据持久化 | `lib/src/pages/tools/meal_planner_page.dart` | P1 | ✅ 已完成 | 接入SharedPreferences持久化,按周保存/加载 | +| 18.5 | 📅 菜单规划-从收藏添加 | `lib/src/pages/tools/meal_planner_page.dart` | P2 | ✅ 已完成 | 实现收藏列表选择弹窗,从FavoritesController获取数据 | +| 18.6 | 🛒 购物清单-从菜谱添加入口 | `lib/src/pages/profile/shopping_list_page.dart` | P1 | ✅ 已完成 | 菜谱详情页已有"购物"按钮,调用addItemsFromRecipe | +| 18.7 | 🥕 食材详情-营养信息 | `lib/src/pages/tools/ingredient_detail_page.dart` | P2 | ✅ 已完成 | 创建60+食材营养数据库,展示热量/蛋白质/脂肪/碳水/纤维+关键营养素+时令+选购技巧+储存方法 | +| 18.8 | 📊 营养中心-饼图+折线图 | `lib/src/pages/profile/nutrition/nutrition_report_page.dart` | P2 | ✅ 已完成 | 新增MealTypePieChart餐次分布饼图,现有折线图+营养素饼图+餐次饼图完整 | + +### 实施记录 + +**第一批(P1 — 核心功能缺失)** +1. **18.1 烹饪笔记页面** — 创建CookingNotePage,支持添加/编辑/删除笔记,从菜谱详情页跳转 +2. **18.2 过敏原检测实现** — AllergenChecker接入PreferenceController,11类过敏原关键词匹配 +3. **18.4 菜单规划持久化** — 使用StorageService+SharedPreferences按周保存/加载,自动清理过期数据 +4. **18.6 购物清单从菜谱添加** — 已确认recipe_detail_page.dart中已有_addToShoppingList方法 + +**第二批(P2 — 功能增强)** +5. **18.3 份量缩放从菜谱导入** — ServingScalerPage支持ingredients+defaultServings参数,路由传递数据 +6. **18.5 菜单规划从收藏添加** — _addFromFavorites方法,CupertinoActionSheet选择收藏菜谱 +7. **18.7 食材详情营养信息** — 创建IngredientNutritionDb(60+食材),页面展示营养概览/占比/关键营养素/时令/选购/储存 +8. **18.8 营养中心饼图+折线图** — 新增MealTypePieChart组件(早/午/晚/加餐热量分布),集成到营养报告页 + +### 新增文件 +- `lib/src/services/data/ingredient_nutrition_db.dart` — 食材营养数据库(60+种常见食材) + +### 验收标准 +- [x] 烹饪笔记:可从菜谱详情进入,支持添加/编辑/删除笔记 +- [x] 过敏原检测:用户设置过敏原后,菜谱详情页显示过敏原警告 +- [x] 菜单规划:退出后重新打开数据不丢失 +- [x] 购物清单:菜谱详情页可一键添加食材到购物清单 +- [x] 份量缩放:支持从菜谱详情页导入真实食材列表 +- [x] 菜单规划:可从收藏列表选择菜谱添加到规划 +- [x] 食材详情:显示营养成分数据和选购建议 +- [x] 营养报告:包含饼图(营养素占比+餐次分布)和折线图(热量趋势) + +--- + +## 🔴 阶段十九:综合Bug修复+功能增强(P0/P1/P2)— 🔄 进行中 + +**目标**:修复用户反馈的多个问题,增强现有功能 +**开始时间**:2026-04-11 +**关键阻塞**:无 +**优先级**:P0=崩溃 P1=功能缺陷 P2=体验优化 + +### 问题清单 + +| 序号 | 任务 | 产出文件 | 优先级 | 状态 | 说明 | +|------|------|---------|--------|------|------| +| 19.1 | 🛠️ 发现页更多按钮卡死 | `lib/src/pages/tools/tools_center_page.dart` | P0 | ✅ 已完成 | 添加ToolsController安全检查,防止未注册时崩溃 | +| 19.2 | ⏱️ 烹饪计时器常用预设 | `lib/src/pages/tools/cooking_timer_page.dart` | P1 | ✅ 已完成 | 添加18种常用烹饪步骤快捷添加(煮鸡蛋/煮面条/炖汤等) | +| 19.3 | 📋 菜谱详情显示全部数据 | `lib/src/pages/home/recipe_detail_page.dart` | P1 | ✅ 已完成 | 显示浏览量/营养成分/分类/标签/过敏原/时间等全部字段,笔记icon横向滚动 | +| 19.4 | 🏷️ 口味偏好分类+标签修复 | `lib/src/models/user_preference_model.dart` | P1 | ✅ 已完成 | 修复PreferenceCategory id字符串解析+children子分类+标签显示 | +| 19.5 | 🔥 热门排行数据修复 | `lib/src/repositories/hot_repository.dart` | P1 | ✅ 已完成 | 添加fallback机制,period无数据时回退total | +| 19.6 | 🛒 购物清单按钮增大 | `lib/src/pages/profile/shopping_list_page.dart` | P2 | ✅ 已完成 | 勾选/删除按钮增大至44x44点击区域,icon增大至22 | +| 19.7 | 🔄 我的页面左右滑动 | `lib/src/pages/profile/profile_page.dart` | P2 | ✅ 已完成 | 使用PageView替代条件渲染,支持手势滑动+SegmentedControl联动 | +| 19.8 | 📝 笔记保存后不显示 | `lib/src/pages/tools/cooking_note_page.dart` | P1 | ✅ 已完成 | 修复异步保存未await+添加Obx响应式刷新 | +| 19.9 | 🌙 深色模式跟随系统 | `lib/src/services/ui/theme_service.dart` | P1 | ✅ 已完成 | 添加DarkModeSource枚举,支持system/manual模式,监听系统亮度变化 | +| 19.10 | 📝 字体大小全局生效 | `lib/main.dart` | P1 | ✅ 已完成 | 通过GetCupertinoApp.builder设置全局textScaleFactor | +| 19.11 | 📱 底部Tab栏高度+安全区 | `lib/src/widgets/glass/glass_nav_bar.dart` | P1 | ✅ 已完成 | 高度64→68,添加底部安全区域padding | +| 19.12 | 🧊 白色区域遮住底部 | `lib/src/widgets/navigation_widgets.dart` | P0 | ✅ 已完成 | 移除SafeArea(bottom:false),GlassNavBar自行处理安全区域 | +| 19.13 | 📂 推荐分类层级导航 | `lib/src/pages/discover/category_browse_page.dart` | P1 | ✅ 已完成 | 大类→小类→菜谱列表→详情,新建CategoryBrowsePage | +| 19.14 | 🎲 今天吃什么GetX报错 | `lib/src/pages/discover/what_to_eat_page.dart` | P0 | ✅ 已完成 | 控制器注册+加载优化+分类扁平化 | +| 19.15 | 🔍 搜索显示相似结果 | `lib/src/pages/home/search_page.dart` | P2 | ✅ 已完成 | 无结果时提取关键词模糊搜索,显示相似推荐列表 | +| 19.16 | ⚙️ 设置功能全局生效 | `lib/src/widgets/navigation_widgets.dart` | P1 | 🔄 进行中 | 底部栏样式已全局生效,动画/对话框等仍需逐项接入 | +| 19.17 | 📖 用餐时段推荐页 | `lib/src/pages/tools/eating_times_page.dart` | P2 | ✅ 已完成 | 基于eating_times.json创建5类时段浏览+菜谱列表 | +| 19.18 | 🌐 网络请求优化 | `lib/src/services/api/api_service.dart` | P1 | ✅ 已完成 | 增强日志+重试机制+统一离线检查+缓存解析修复 | + +### 实施记录 + +**19.1 发现页更多按钮卡死** +- 问题:点击收藏页"更多"按钮跳转工具中心时,ToolsController未注册导致崩溃 +- 方案:添加Get.isRegistered检查,未注册时显示友好错误页面和返回按钮 + +**19.2 烹饪计时器常用预设** +- 问题:添加步骤时需要手动输入名称和时长 +- 方案:添加18种常用烹饪预设(煮鸡蛋10min、煮面条8min、炖汤45min等),点击自动填充 + +**19.3 菜谱详情显示全部数据** +- 问题:详情页只显示部分数据,笔记icon溢出屏幕 +- 方案:添加statistics/tags/meta/categorizedIngredients/allergens等全部字段显示,笔记按钮改为横向滚动 + +**19.4 口味偏好分类+标签修复** +- 问题:API返回id为字符串但模型解析为int导致null,children未解析 +- 方案:PreferenceCategory添加动态类型解析(_parseInt),添加children字段和子分类显示 + +**19.8 笔记保存后不显示** +- 问题:addNote是异步方法但未await,setState在数据保存前执行 +- 方案:await addNote后再setState,使用Obx包裹_buildBody实现响应式刷新 + +**19.9 深色模式跟随系统** +- 问题:深色模式只能手动切换,不支持跟随系统 +- 方案:添加DarkModeSource枚举(system/manual),main.dart添加WidgetsBindingObserver监听系统亮度变化 + +**19.10 字体大小全局生效** +- 问题:字体大小设置只在个性化设置页面可见 +- 方案:通过GetCupertinoApp.builder包裹MediaQuery,设置全局textScaleFactor + +**19.12 白色区域遮住底部** +- 问题:SafeArea(bottom:false)导致底部导航栏下方出现白色区域 +- 方案:移除外层SafeArea,GlassNavBar自行通过MediaQuery.padding.bottom处理安全区域 + +**19.13 推荐分类层级导航** +- 问题:推荐Tab显示假数据 +- 方案:创建CategoryBrowsePage,实现大类→小类→菜谱列表三级导航,CategoryModel添加children字段 + +### 待解决问题 +- 设置功能全局生效:底部栏样式已接入,动画/对话框/消息气泡/卡片方向等仍需逐项接入各页面 +- API扩展功能:参考API文档扩展更多接口能力(阶段十四/十五) + +**19.14 今天吃什么GetX报错** +- 问题:页面跳转时控制器未注册,分类数据加载慢 +- 方案:WhatToEatController在AppBinding注册,分类扁平化处理(食材1413子类限制20个),添加12秒超时 + +**19.15 搜索显示相似结果** +- 问题:搜索无结果时直接显示空白 +- 方案:SearchController添加_searchSimilar方法,提取关键词子串+常见食材后缀进行模糊搜索,空结果视图显示相似推荐列表 + +**19.17 用餐时段推荐页** +- 问题:设置页需要基于eating_times.json扩展新功能 +- 方案:创建EatingTimesPage,从API获取5类时段数据(standard/combined/frequency/method/other),点击时段搜索菜谱,EatingTimeRecipesPage显示结果 + +**19.18 网络请求优化** +- 问题:ApiService日志拦截器无输出、无重试机制、post/put/delete重复代码 +- 方案:增强日志拦截器(debugPrint请求/响应/错误)、添加_executeWithRetry(最多2次重试)、统一_executeWithOfflineCheck、修复_tryGetCache缓存数据jsonDecode diff --git a/lib/main.dart b/lib/main.dart index fb680e8..b1ec422 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -59,11 +59,12 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { final themeService = Get.put(ThemeService.instance); - return Obx( - () => GetCupertinoApp( + return Obx(() { + final textScale = themeService.fontSize.value / 16.0; + return GetCupertinoApp( title: 'Mom\'s Kitchen', key: ValueKey( - 'theme_${themeService.primaryColor.value.toARGB32()}_${themeService.isDarkMode.value}', + 'theme_${themeService.primaryColor.value.toARGB32()}_${themeService.isDarkMode.value}_${themeService.fontSize.value}', ), theme: themeService.cupertinoThemeData, locale: Locale(themeService.currentLocale.value), @@ -81,14 +82,22 @@ class MyApp extends StatelessWidget { ], getPages: AppRoutes.pages, initialBinding: AppBinding(), + builder: (context, widget) { + return MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: widget!, + ); + }, routingCallback: (routing) { if (kDebugMode && routing?.current != null) { AppLogger.d('🔍 路由变化: ${routing?.previous} → ${routing?.current}'); } }, home: const _InitWrapper(), - ), - ); + ); + }); } } @@ -99,10 +108,12 @@ class _InitWrapper extends StatefulWidget { State<_InitWrapper> createState() => _InitWrapperState(); } -class _InitWrapperState extends State<_InitWrapper> { +class _InitWrapperState extends State<_InitWrapper> + with WidgetsBindingObserver { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { ToastService.init(context); @@ -111,6 +122,21 @@ class _InitWrapperState extends State<_InitWrapper> { }); } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangePlatformBrightness() { + super.didChangePlatformBrightness(); + final brightness = + WidgetsBinding.instance.platformDispatcher.platformBrightness; + final themeService = Get.find(); + themeService.onSystemBrightnessChanged(brightness); + } + @override Widget build(BuildContext context) { return const CupertinoPageScaffold( diff --git a/lib/src/app_binding.dart b/lib/src/app_binding.dart index 8512026..f1ad688 100644 --- a/lib/src/app_binding.dart +++ b/lib/src/app_binding.dart @@ -1,6 +1,7 @@ // 2026-04-09 | AppBinding | 全局Binding | Web端跳过permission注册 // 2026-04-10 | 移除 CartController 注册(收藏功能统一使用 FavoritesController) // 2026-04-10 | 新增 ShoppingListController 全局注册(首页需要使用) +// 2026-04-11 | 统一控制器生命周期管理 | 新增ToolsController/HotController/WhatToEatController全局注册 | 移除路由级重复注册 import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; import 'package:mom_kitchen/src/controllers/feed/action_controller.dart'; @@ -15,14 +16,18 @@ import 'package:mom_kitchen/src/controllers/main_navigation_controller.dart'; import 'package:mom_kitchen/src/controllers/meal_record_controller.dart'; import 'package:mom_kitchen/src/controllers/search_controller.dart'; import 'package:mom_kitchen/src/controllers/shopping_list_controller.dart'; +import 'package:mom_kitchen/src/controllers/cooking_note_controller.dart'; import 'package:mom_kitchen/src/controllers/tools_controller.dart'; import 'package:mom_kitchen/src/controllers/what_to_eat_controller.dart'; import 'package:mom_kitchen/src/services/core/app_service.dart'; import 'package:mom_kitchen/src/services/ui/theme_service.dart'; +/// 全局Binding - 应用启动时注册所有全局控制器和服务 +/// 所有 permanent:true 的控制器在此统一管理,路由级Binding禁止重复注册 class AppBinding extends Bindings { @override void dependencies() { + // --- 服务层(lazyPut + fenix: 允许被回收后自动重建) --- Get.lazyPut(() => AppService.instance.api, fenix: true); Get.lazyPut(() => AppService.instance.storage, fenix: true); if (!kIsWeb && AppService.instance.permission != null) { @@ -34,9 +39,11 @@ class AppBinding extends Bindings { Get.lazyPut(() => AppService.instance.appInfo, fenix: true); Get.lazyPut(() => AppService.instance.toast, fenix: true); + // --- 主题与个性化 --- Get.put(ThemeService.instance, permanent: true); Get.put(PersonalizationController(), permanent: true); + // --- 核心业务控制器(全局永久,路由级Binding中禁止重复注册) --- Get.put(ActionController(), permanent: true); Get.put(FavoritesController(), permanent: true); Get.put(ShoppingListController(), permanent: true); @@ -45,40 +52,14 @@ class AppBinding extends Bindings { Get.put(PreferenceController(), permanent: true); Get.put(ProfileController(), permanent: true); Get.put(MainNavigationController(), permanent: true); + Get.put(ToolsController(), permanent: true); + Get.put(HotController(), permanent: true); + Get.put(WhatToEatController(), permanent: true); + Get.put(CookingNoteController(), permanent: true); } } -class MainBinding extends Bindings { - @override - void dependencies() { - Get.put(HotController()); - Get.put(WhatToEatController()); - Get.put(FavoritesController(), permanent: true); - Get.put(ShoppingListController(), permanent: true); - } -} - -class DiscoverBinding extends Bindings { - @override - void dependencies() { - Get.lazyPut(() => HotController()); - } -} - -class HotBinding extends Bindings { - @override - void dependencies() { - Get.lazyPut(() => HotController()); - } -} - -class WhatToEatBinding extends Bindings { - @override - void dependencies() { - Get.lazyPut(() => WhatToEatController()); - } -} - +/// 搜索页Binding - SearchController仅搜索页使用,按需创建 class SearchBinding extends Bindings { @override void dependencies() { @@ -86,6 +67,7 @@ class SearchBinding extends Bindings { } } +/// 营养页Binding - MealRecordController带注册检查,避免重复 class NutritionBinding extends Bindings { @override void dependencies() { @@ -94,34 +76,3 @@ class NutritionBinding extends Bindings { } } } - -class ShoppingBinding extends Bindings { - @override - void dependencies() { - Get.lazyPut(() => ShoppingListController()); - } -} - -class FavoritesBinding extends Bindings { - @override - void dependencies() { - Get.put(FavoritesController(), permanent: true); - Get.put(ToolsController(), permanent: true); - } -} - -class RecipeDetailBinding extends Bindings { - @override - void dependencies() { - Get.put(FavoritesController(), permanent: true); - Get.put(ActionController(), permanent: true); - Get.put(ShoppingListController(), permanent: true); - } -} - -class ToolsBinding extends Bindings { - @override - void dependencies() { - Get.put(ToolsController(), permanent: true); - } -} diff --git a/lib/src/config/app_config.dart b/lib/src/config/app_config.dart index e677655..e2b2d38 100644 --- a/lib/src/config/app_config.dart +++ b/lib/src/config/app_config.dart @@ -14,7 +14,7 @@ class AppConfig { static String get fullVersion => AppInfoService().fullVersion; // API 基础 URL - static const String baseUrl = 'https://api.example.com'; + static const String baseUrl = 'https://eat.wktyl.com'; // 超时时间(秒) static const int timeoutSeconds = 10; diff --git a/lib/src/config/app_routes.dart b/lib/src/config/app_routes.dart index e1e63c7..3278894 100644 --- a/lib/src/config/app_routes.dart +++ b/lib/src/config/app_routes.dart @@ -26,6 +26,9 @@ import 'package:mom_kitchen/src/pages/tools/allergen_checker_page.dart'; import 'package:mom_kitchen/src/pages/tools/meal_time_recommend_page.dart'; import 'package:mom_kitchen/src/pages/tools/meal_planner_page.dart'; import 'package:mom_kitchen/src/pages/tools/ingredient_detail_page.dart'; +import 'package:mom_kitchen/src/pages/tools/cooking_note_page.dart'; +import 'package:mom_kitchen/src/pages/discover/category_browse_page.dart'; +import 'package:mom_kitchen/src/pages/tools/eating_times_page.dart'; import 'package:mom_kitchen/src/app_binding.dart'; class AppRoutes { @@ -59,6 +62,9 @@ class AppRoutes { static const String toolsIngredient = '/tools/ingredient'; static const String toolsStats = '/tools/stats'; static const String toolsPlanner = '/tools/planner'; + static const String cookingNote = '/cooking-note'; + static const String categoryBrowse = '/category-browse'; + static const String eatingTimes = '/eating-times'; static final List pages = [ GetPage( @@ -74,13 +80,11 @@ class AppRoutes { GetPage( name: favorites, page: () => const FavoritesPage(), - binding: FavoritesBinding(), middlewares: [PageStandardsMiddleware()], ), GetPage( name: discover, page: () => const DiscoverPage(), - binding: DiscoverBinding(), middlewares: [PageStandardsMiddleware()], ), GetPage( @@ -101,19 +105,16 @@ class AppRoutes { GetPage( name: main, page: () => const MainTabView(), - binding: MainBinding(), middlewares: [PageStandardsMiddleware()], ), GetPage( name: whatToEat, page: () => const WhatToEatPage(), - binding: WhatToEatBinding(), middlewares: [PageStandardsMiddleware()], ), GetPage( name: '/hot', page: () => const HotPage(), - binding: HotBinding(), middlewares: [PageStandardsMiddleware()], ), GetPage( @@ -137,7 +138,6 @@ class AppRoutes { GetPage( name: shoppingList, page: () => const ShoppingListPage(), - binding: ShoppingBinding(), middlewares: [PageStandardsMiddleware()], ), GetPage( @@ -149,7 +149,6 @@ class AppRoutes { GetPage( name: recipeDetail, page: () => RecipeDetailPage(recipeId: Get.arguments), - binding: RecipeDetailBinding(), middlewares: [PageStandardsMiddleware()], ), GetPage( @@ -169,13 +168,15 @@ class AppRoutes { ), GetPage( name: servingScaler, - page: () => const ServingScalerPage(), + page: () => ServingScalerPage( + ingredients: Get.arguments?['ingredients'], + defaultServings: Get.arguments?['servings'], + ), middlewares: [PageStandardsMiddleware()], ), GetPage( name: toolsCenter, page: () => const ToolsCenterPage(), - binding: ToolsBinding(), middlewares: [PageStandardsMiddleware()], ), GetPage( @@ -185,7 +186,10 @@ class AppRoutes { ), GetPage( name: toolsScaler, - page: () => const ServingScalerPage(), + page: () => ServingScalerPage( + ingredients: Get.arguments?['ingredients'], + defaultServings: Get.arguments?['servings'], + ), middlewares: [PageStandardsMiddleware()], ), GetPage( @@ -227,7 +231,27 @@ class AppRoutes { GetPage( name: toolsStats, page: () => const HotPage(), - binding: HotBinding(), + middlewares: [PageStandardsMiddleware()], + ), + GetPage( + name: cookingNote, + page: () => CookingNotePage( + recipeId: Get.arguments?['recipeId'] ?? '', + recipeTitle: Get.arguments?['recipeTitle'] ?? '', + ), + middlewares: [PageStandardsMiddleware()], + ), + GetPage( + name: categoryBrowse, + page: () => CategoryBrowsePage( + category: Get.arguments?['category'], + title: Get.arguments?['title'] ?? '分类浏览', + ), + middlewares: [PageStandardsMiddleware()], + ), + GetPage( + name: eatingTimes, + page: () => const EatingTimesPage(), middlewares: [PageStandardsMiddleware()], ), ]; diff --git a/lib/src/controllers/cooking_note_controller.dart b/lib/src/controllers/cooking_note_controller.dart index acc2a2c..0f0194f 100644 --- a/lib/src/controllers/cooking_note_controller.dart +++ b/lib/src/controllers/cooking_note_controller.dart @@ -1,9 +1,9 @@ // 烹饪笔记控制器 // 创建时间: 2026-04-09 -// 更新时间: 2026-04-09 +// 更新时间: 2026-04-11 // 名称: cooking_note_controller.dart // 作用: 管理烹饪笔记的增删改查 -// 上次更新内容: 初始创建 +// 上次更新内容: 修复HiveService获取方式,使用工厂构造函数而非Get.find import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; @@ -13,7 +13,7 @@ import '../services/data/hive_service.dart'; class CookingNoteController extends GetxController { static CookingNoteController get to => Get.find(); - final HiveService _hiveService = Get.find(); + final HiveService _hiveService = HiveService(); final RxList _notes = [].obs; List get notes => _notes; diff --git a/lib/src/controllers/search_controller.dart b/lib/src/controllers/search_controller.dart index 8486250..c2fb8d0 100644 --- a/lib/src/controllers/search_controller.dart +++ b/lib/src/controllers/search_controller.dart @@ -1,4 +1,5 @@ // 2026-04-10 | SearchController | 搜索控制器 | 完全重写,直接调用api.php search接口 +// 2026-04-11 | 添加从API获取热门搜索词功能,替代硬编码热词 import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; import 'package:mom_kitchen/src/controllers/base_controller.dart'; @@ -7,11 +8,17 @@ import 'package:mom_kitchen/src/config/api_config.dart'; import 'package:mom_kitchen/src/services/api/api_service.dart'; import 'package:mom_kitchen/src/services/data/hive_service.dart'; import 'package:mom_kitchen/src/services/ui/toast_service.dart'; +import 'package:mom_kitchen/src/repositories/recipe_repository.dart'; class SearchController extends BaseController { final RxString searchQuery = ''.obs; final RxList searchHistory = [].obs; - final RxList hotSearches = [ + final RxList hotSearches = [].obs; + final RxList searchResults = [].obs; + final RxList similarResults = [].obs; + final RxBool hasSimilarResults = false.obs; + + static const List _fallbackHotSearches = [ '红烧肉', '糖醋排骨', '麻婆豆腐', @@ -22,15 +29,34 @@ class SearchController extends BaseController { '水煮肉片', '可乐鸡翅', '回锅肉', - ].obs; - final RxList searchResults = [].obs; + ]; final ApiService _apiService = ApiService(); + final RecipeRepository _recipeRepository = RecipeRepository(); @override void onInit() { super.onInit(); loadSearchHistory(); + loadHotSearches(); + } + + Future loadHotSearches() async { + try { + final tags = await _recipeRepository.fetchTags(limit: 20); + if (tags.isNotEmpty) { + hotSearches.value = tags + .where((t) => t.name.isNotEmpty) + .map((t) => t.name) + .take(20) + .toList(); + } + } catch (e) { + debugPrint('Load hot searches error: $e'); + } + if (hotSearches.isEmpty) { + hotSearches.value = List.from(_fallbackHotSearches); + } } void loadSearchHistory() { @@ -124,8 +150,17 @@ class SearchController extends BaseController { searchResults.value = results; if (results.isEmpty) { - ToastService.show(message: '未找到"${searchQuery.value}"相关菜谱,试试其他关键词 🔍'); + await _searchSimilar(searchQuery.value); + if (similarResults.isNotEmpty) { + ToastService.show(message: '未找到"${searchQuery.value}",为您推荐相似菜谱 🔍'); + } else { + ToastService.show( + message: '未找到"${searchQuery.value}"相关菜谱,试试其他关键词 🔍', + ); + } } else { + similarResults.clear(); + hasSimilarResults.value = false; ToastService.show(message: '找到 ${results.length} 个相关菜谱 🎉'); } } catch (e) { @@ -190,6 +225,86 @@ class SearchController extends BaseController { void clearResults() { searchQuery.value = ''; searchResults.clear(); + similarResults.clear(); + hasSimilarResults.value = false; + } + + Future _searchSimilar(String keyword) async { + similarResults.clear(); + hasSimilarResults.value = false; + + try { + final keywords = _extractSimilarKeywords(keyword); + if (keywords.isEmpty) return; + + final response = await _apiService + .get( + ApiConfig.recipe, + queryParameters: { + 'act': 'search', + 'keyword': keywords.first, + 'type': 'all', + 'page': 1, + 'limit': 10, + }, + ) + .timeout(const Duration(seconds: 8)); + + if (response.data == null) return; + + final data = response.data as Map; + if (data['code'] != null && data['code'] != 200) return; + + final resultData = data['data']; + if (resultData is! Map) return; + + final result = resultData['result']; + if (result is! Map) return; + + final recipes = result['recipes']; + if (recipes is List && recipes.isNotEmpty) { + for (final item in recipes) { + if (item is Map) { + try { + similarResults.add(RecipeModel.fromJson(item)); + } catch (_) { + similarResults.add( + RecipeModel( + id: _safeInt(item['id']), + title: + _safeString(item['title']) ?? + _safeString(item['name']) ?? + '未知菜谱', + intro: _safeString(item['intro']), + cover: _safeString(item['cover']), + ), + ); + } + } + } + hasSimilarResults.value = similarResults.isNotEmpty; + } + } catch (e) { + debugPrint('Search similar error: $e'); + } + } + + List _extractSimilarKeywords(String keyword) { + final keywords = []; + if (keyword.length >= 2) { + keywords.add(keyword.substring(0, keyword.length - 1)); + } + if (keyword.length >= 3) { + keywords.add(keyword.substring(1)); + keywords.add(keyword.substring(0, keyword.length ~/ 2 + 1)); + } + final commonSuffixes = ['肉', '鸡', '鱼', '汤', '面', '饭', '虾', '蛋', '豆腐', '排骨']; + for (final suffix in commonSuffixes) { + if (keyword.contains(suffix)) { + keywords.add(suffix); + } + } + return keywords.toSet().toList(); } void searchByKeyword(String keyword) { diff --git a/lib/src/controllers/user/personalization_controller.dart b/lib/src/controllers/user/personalization_controller.dart index fffe9ec..74f9123 100644 --- a/lib/src/controllers/user/personalization_controller.dart +++ b/lib/src/controllers/user/personalization_controller.dart @@ -100,6 +100,11 @@ class PersonalizationController extends BaseController { await _themeService.toggleThemeMode(); } + /// 设置深色模式来源 + Future setDarkModeSource(DarkModeSource source) async { + await _themeService.setDarkModeSource(source); + } + /// 设置动画强度 Future setAnimationIntensity(double intensity) async { await _themeService.setAnimationIntensity(intensity); diff --git a/lib/src/controllers/what_to_eat_controller.dart b/lib/src/controllers/what_to_eat_controller.dart index 4c2c54d..59670fb 100644 --- a/lib/src/controllers/what_to_eat_controller.dart +++ b/lib/src/controllers/what_to_eat_controller.dart @@ -77,11 +77,36 @@ class WhatToEatController extends BaseController { Future _loadCategories() async { try { final result = await _recipeRepository.fetchCategories(); - debugPrint('WhatToEatController: loaded ${result.length} categories'); - for (final c in result.take(5)) { - debugPrint(' category: ${c.id}, ${c.name}, parentId=${c.parentId}'); + debugPrint('WhatToEatController: loaded ${result.length} top categories'); + + final allCategories = []; + for (final topCat in result) { + allCategories.add(topCat); + if (topCat.children.isNotEmpty) { + final childrenToShow = topCat.id == 1000 + ? topCat.children.take(20) + : topCat.children; + for (final child in childrenToShow) { + allCategories.add( + CategoryModel( + id: child.id, + name: child.name, + description: child.description, + icon: child.icon, + parentId: child.parentId ?? topCat.id, + sortOrder: child.sortOrder, + count: child.count, + children: child.children, + ), + ); + } + } } - categories.value = result; + + debugPrint( + 'WhatToEatController: total ${allCategories.length} categories (including children)', + ); + categories.value = allCategories; } catch (e) { debugPrint('Load categories error: $e'); } @@ -105,7 +130,7 @@ class WhatToEatController extends BaseController { errorMessage.value = ''; try { - final categoryIds = selectedCategories + final rawCategoryIds = selectedCategories .map((c) => c.id) .where((id) => id > 0) .toList(); @@ -114,6 +139,16 @@ class WhatToEatController extends BaseController { .where((id) => id > 0) .toList(); + final categoryIds = []; + for (final catId in rawCategoryIds) { + final subCats = getSubCategories(catId); + if (subCats.isNotEmpty) { + categoryIds.addAll(subCats.take(50).map((c) => c.id)); + } else { + categoryIds.add(catId); + } + } + debugPrint( 'WhatToEatController.roll: categories=$categoryIds, tags=$tagIds', ); diff --git a/lib/src/models/recipe/category_model.dart b/lib/src/models/recipe/category_model.dart index a3c6a50..417bef9 100644 --- a/lib/src/models/recipe/category_model.dart +++ b/lib/src/models/recipe/category_model.dart @@ -1,4 +1,5 @@ // 2026-04-09 | CategoryModel | 分类数据模型 | 对齐api.php?act=categories返回字段 +// 2026-04-11 | 添加children字段支持子分类 class CategoryModel { final int id; final String name; @@ -7,6 +8,7 @@ class CategoryModel { final int? parentId; final int? sortOrder; final int? count; + final List children; const CategoryModel({ required this.id, @@ -16,6 +18,7 @@ class CategoryModel { this.parentId, this.sortOrder, this.count, + this.children = const [], }); String get displayIcon => icon ?? '📁'; @@ -29,9 +32,18 @@ class CategoryModel { parentId: _parseIntOrNull(json['parent_id']), sortOrder: _parseIntOrNull(json['sort_order'] ?? json['order']), count: _parseIntOrNull(json['count'] ?? json['post_count']), + children: _parseChildren(json['children']), ); } + static List _parseChildren(dynamic value) { + if (value is! List) return []; + return value + .whereType>() + .map((e) => CategoryModel.fromJson(e)) + .toList(); + } + static int _parseInt(dynamic value) { if (value == null) return 0; if (value is int) return value; diff --git a/lib/src/models/user_preference_model.dart b/lib/src/models/user_preference_model.dart index 82fa98f..cb9a71a 100644 --- a/lib/src/models/user_preference_model.dart +++ b/lib/src/models/user_preference_model.dart @@ -60,9 +60,11 @@ class UserPreferenceModel { static List _parseAllergens(dynamic json) { if (json is! List) return []; return json - .map((e) => e is Map - ? AllergenItem.fromJson(e) - : AllergenItem(type: e.toString(), name: e.toString())) + .map( + (e) => e is Map + ? AllergenItem.fromJson(e) + : AllergenItem(type: e.toString(), name: e.toString()), + ) .toList(); } } @@ -71,16 +73,56 @@ class PreferenceCategory { final int id; final String name; final String? icon; + final List children; - const PreferenceCategory({required this.id, required this.name, this.icon}); + const PreferenceCategory({ + required this.id, + required this.name, + this.icon, + this.children = const [], + }); factory PreferenceCategory.fromJson(Map json) { return PreferenceCategory( - id: json['id'] as int? ?? json['category_id'] as int? ?? 0, - name: json['name'] as String? ?? json['category_name'] as String? ?? '', - icon: json['icon'] as String? ?? json['category_icon'] as String?, + id: _parseInt(json['id'] ?? json['category_id']), + name: _parseString(json['name'] ?? json['category_name']) ?? '', + icon: _parseString(json['icon'] ?? json['category_icon']), + children: _parseChildren(json['children']), ); } + + static int _parseInt(dynamic value) { + if (value == null) return 0; + if (value is int) return value; + if (value is String) return int.tryParse(value) ?? 0; + if (value is double) return value.toInt(); + return 0; + } + + static String? _parseString(dynamic value) { + if (value == null) return null; + if (value is String) return value.isEmpty ? null : value; + return null; + } + + static List _parseChildren(dynamic value) { + if (value is! List) return []; + return value + .whereType>() + .map((e) => PreferenceCategory.fromJson(e)) + .toList(); + } + + String get displayIcon => icon ?? '📂'; + + List get allDescendants { + final result = []; + for (final child in children) { + result.add(child); + result.addAll(child.allDescendants); + } + return result; + } } class PreferenceTag { @@ -91,10 +133,24 @@ class PreferenceTag { factory PreferenceTag.fromJson(Map json) { return PreferenceTag( - id: json['id'] as int? ?? json['tag_id'] as int? ?? 0, - name: json['name'] as String? ?? json['tag_name'] as String? ?? '', + id: _parseInt(json['id'] ?? json['tag_id']), + name: _parseString(json['name'] ?? json['tag_name']) ?? '', ); } + + static int _parseInt(dynamic value) { + if (value == null) return 0; + if (value is int) return value; + if (value is String) return int.tryParse(value) ?? 0; + if (value is double) return value.toInt(); + return 0; + } + + static String? _parseString(dynamic value) { + if (value == null) return null; + if (value is String) return value.isEmpty ? null : value; + return null; + } } class AllergenItem { diff --git a/lib/src/pages/discover/category_browse_page.dart b/lib/src/pages/discover/category_browse_page.dart new file mode 100644 index 0000000..0b898eb --- /dev/null +++ b/lib/src/pages/discover/category_browse_page.dart @@ -0,0 +1,451 @@ +/* + * 文件: category_browse_page.dart + * 名称: 分类浏览页面 + * 作用: 分类层级导航,大类→小类→菜谱列表 + * 创建: 2026-04-11 + * 更新: 2026-04-11 初始创建 + */ + +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/category_model.dart'; +import 'package:mom_kitchen/src/models/recipe/recipe_model.dart'; +import 'package:mom_kitchen/src/repositories/recipe_repository.dart'; + +class CategoryBrowsePage extends StatefulWidget { + final CategoryModel? category; + final String title; + + const CategoryBrowsePage({super.key, this.category, this.title = '分类浏览'}); + + @override + State createState() => _CategoryBrowsePageState(); +} + +class _CategoryBrowsePageState extends State { + final RecipeRepository _repo = RecipeRepository(); + List _categories = []; + List _recipes = []; + bool _isLoading = true; + CategoryModel? _selectedSubCategory; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + setState(() => _isLoading = true); + try { + if (widget.category != null && widget.category!.children.isNotEmpty) { + _categories = widget.category!.children; + } else { + _categories = await _repo.fetchCategories(); + } + if (_categories.isNotEmpty && widget.category != null) { + await _loadRecipes(_categories.first.id); + } + } catch (e) { + debugPrint('CategoryBrowsePage load error: $e'); + } + setState(() => _isLoading = false); + } + + Future _loadRecipes(int categoryId) async { + setState(() { + _isLoading = true; + _recipes = []; + }); + try { + final result = await _repo.fetchList( + categoryId: categoryId, + page: 1, + limit: 20, + ); + setState(() => _recipes = result.items); + } catch (e) { + debugPrint('CategoryBrowsePage loadRecipes error: $e'); + } + setState(() => _isLoading = false); + } + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return CupertinoPageScaffold( + backgroundColor: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + navigationBar: CupertinoNavigationBar( + middle: Text( + widget.title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + backgroundColor: isDark + ? DarkDesignTokens.background.withValues(alpha: 0.9) + : DesignTokens.background.withValues(alpha: 0.9), + border: null, + ), + child: SafeArea( + top: false, + child: _isLoading + ? const Center(child: CupertinoActivityIndicator()) + : _categories.isEmpty + ? _buildEmptyState(isDark) + : _buildContent(isDark), + ), + ); + } + + Widget _buildEmptyState(bool isDark) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('📂', style: TextStyle(fontSize: 56)), + const SizedBox(height: DesignTokens.space4), + Text( + '暂无分类数据', + style: TextStyle( + fontSize: DesignTokens.fontLg, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ); + } + + Widget _buildContent(bool isDark) { + if (widget.category != null && widget.category!.children.isNotEmpty) { + return _buildSubCategoryView(isDark); + } + return _buildTopCategoryGrid(isDark); + } + + Widget _buildTopCategoryGrid(bool isDark) { + return GridView.builder( + padding: const EdgeInsets.all(DesignTokens.space4), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: DesignTokens.space3, + crossAxisSpacing: DesignTokens.space3, + childAspectRatio: 1.2, + ), + itemCount: _categories.length, + itemBuilder: (context, index) { + final cat = _categories[index]; + return _buildCategoryCard(cat, isDark, isTopLevel: true); + }, + ); + } + + Widget _buildCategoryCard( + CategoryModel cat, + bool isDark, { + bool isTopLevel = false, + }) { + final hasChildren = cat.children.isNotEmpty; + + return GestureDetector( + onTap: () { + if (hasChildren) { + Get.toNamed( + '/category-browse', + arguments: {'category': cat, 'title': cat.name}, + ); + } else { + Get.toNamed( + '/category-browse', + arguments: {'category': cat, 'title': cat.name}, + ); + } + }, + child: Container( + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.1), + ), + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + cat.displayIcon, + style: TextStyle(fontSize: isTopLevel ? 36 : 28), + ), + const SizedBox(height: DesignTokens.space2), + Text( + cat.name, + style: TextStyle( + fontSize: isTopLevel + ? DesignTokens.fontMd + : DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (cat.count != null && cat.count! > 0) ...[ + const SizedBox(height: DesignTokens.space1), + Text( + '${cat.count} 道菜谱', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + if (hasChildren) ...[ + const SizedBox(height: DesignTokens.space1), + Icon( + CupertinoIcons.chevron_forward, + size: 14, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ], + ], + ), + ), + ); + } + + Widget _buildSubCategoryView(bool isDark) { + return Column( + children: [ + SizedBox( + height: 44, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + itemCount: _categories.length, + separatorBuilder: (_, __) => + const SizedBox(width: DesignTokens.space2), + itemBuilder: (context, index) { + final cat = _categories[index]; + final isSelected = _selectedSubCategory?.id == cat.id; + return GestureDetector( + onTap: () { + setState(() => _selectedSubCategory = cat); + _loadRecipes(cat.id); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: isSelected + ? (isDark + ? DarkDesignTokens.primary + : DesignTokens.primary) + : (isDark ? DarkDesignTokens.card : DesignTokens.card), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected + ? (isDark + ? DarkDesignTokens.primary + : DesignTokens.primary) + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.15)), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + cat.displayIcon, + style: const TextStyle(fontSize: 14), + ), + const SizedBox(width: 4), + Text( + cat.name, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w400, + color: isSelected + ? CupertinoColors.white + : (isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1), + ), + ), + ], + ), + ), + ); + }, + ), + ), + const SizedBox(height: DesignTokens.space3), + Expanded( + child: _recipes.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🍽️', style: TextStyle(fontSize: 48)), + const SizedBox(height: DesignTokens.space3), + Text( + '暂无菜谱', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + itemCount: _recipes.length, + itemBuilder: (context, index) { + final recipe = _recipes[index]; + return _buildRecipeCard(recipe, isDark); + }, + ), + ), + ], + ); + } + + Widget _buildRecipeCard(RecipeModel recipe, bool isDark) { + return Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space3), + child: GestureDetector( + onTap: () { + Get.toNamed('/recipe-detail', arguments: '${recipe.id}'); + }, + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.1), + ), + ), + child: Row( + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: DesignTokens.primaryLight, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: recipe.cover != null && recipe.cover!.isNotEmpty + ? ClipRRect( + borderRadius: DesignTokens.borderRadiusMd, + child: Image.network( + recipe.cover!, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Center( + child: Icon( + CupertinoIcons.photo, + color: DesignTokens.primary, + size: 24, + ), + ), + ), + ) + : const Center( + child: Icon( + CupertinoIcons.photo, + color: DesignTokens.primary, + size: 24, + ), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recipe.title ?? '', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + if (recipe.meta?.time != null && + recipe.meta!.time!.isNotEmpty) + _buildInfoChip('⏱️ ${recipe.meta!.time}', isDark), + if (recipe.meta?.difficulty != null && + recipe.meta!.difficulty!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 8), + child: _buildInfoChip( + '📊 ${recipe.meta!.difficulty}', + isDark, + ), + ), + ], + ), + ], + ), + ), + Icon( + CupertinoIcons.chevron_forward, + size: 16, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ], + ), + ), + ), + ); + } + + Widget _buildInfoChip(String text, bool isDark) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.primary : DesignTokens.primary) + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + text, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.primary : DesignTokens.primary, + ), + ), + ); + } +} diff --git a/lib/src/pages/discover/discover_page.dart b/lib/src/pages/discover/discover_page.dart index ec19ee7..0331eb9 100644 --- a/lib/src/pages/discover/discover_page.dart +++ b/lib/src/pages/discover/discover_page.dart @@ -10,6 +10,8 @@ import 'package:get/get.dart'; import 'package:badges/badges.dart' as badges; import 'package:mom_kitchen/src/config/design_tokens.dart'; import 'package:mom_kitchen/src/config/app_routes.dart'; +import 'package:mom_kitchen/src/models/recipe/category_model.dart'; +import 'package:mom_kitchen/src/repositories/recipe_repository.dart'; import 'package:mom_kitchen/src/widgets/glass/glass_search_bar.dart'; import 'package:mom_kitchen/src/widgets/glass/glass_segmented_control.dart'; import 'package:mom_kitchen/src/controllers/feed/hot_controller.dart'; @@ -26,11 +28,30 @@ class DiscoverPage extends StatefulWidget { class _DiscoverPageState extends State { int _segmentIndex = 0; late HotController _hotController; + final RecipeRepository _recipeRepo = RecipeRepository(); + List _topCategories = []; + bool _isLoadingCategories = true; @override void initState() { super.initState(); _hotController = Get.find(); + _loadCategories(); + } + + Future _loadCategories() async { + try { + final categories = await _recipeRepo.fetchCategories(); + if (mounted) { + setState(() { + _topCategories = categories; + _isLoadingCategories = false; + }); + } + } catch (e) { + debugPrint('DiscoverPage loadCategories error: $e'); + if (mounted) setState(() => _isLoadingCategories = false); + } } @override @@ -452,35 +473,77 @@ class _DiscoverPageState extends State { } Widget _buildRecommendSection(bool isDark) { - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), - itemCount: 5, + if (_isLoadingCategories) { + return const Center(child: CupertinoActivityIndicator()); + } + + if (_topCategories.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('📂', style: TextStyle(fontSize: 56)), + const SizedBox(height: DesignTokens.space4), + Text( + '暂无分类数据', + style: TextStyle( + fontSize: DesignTokens.fontLg, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ); + } + + return GridView.builder( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: DesignTokens.space3, + crossAxisSpacing: DesignTokens.space3, + childAspectRatio: 1.1, + ), + itemCount: _topCategories.length, itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.only(bottom: DesignTokens.space3), + final cat = _topCategories[index]; + final hasChildren = cat.children.isNotEmpty; + + return GestureDetector( + onTap: () { + Get.toNamed( + '/category-browse', + arguments: {'category': cat, 'title': cat.name}, + ); + }, child: Container( decoration: BoxDecoration( color: isDark ? DarkDesignTokens.card : DesignTokens.card, borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.1), + ), boxShadow: DesignTokens.shadowsSm, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Stack( children: [ - Container( - height: 140, - decoration: BoxDecoration( - color: DesignTokens.primaryLight, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(DesignTokens.radiusLg), - topRight: Radius.circular(DesignTokens.radiusLg), - ), - ), - child: const Center( - child: Icon( - CupertinoIcons.photo, - size: 36, - color: DesignTokens.primary, + Positioned( + right: -10, + bottom: -10, + child: Text( + cat.displayIcon, + style: TextStyle( + fontSize: 72, + color: + (isDark + ? DarkDesignTokens.primary + : DesignTokens.primary) + .withValues(alpha: 0.08), ), ), ), @@ -490,25 +553,49 @@ class _DiscoverPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '推荐菜谱 ${index + 1}', + cat.displayIcon, + style: const TextStyle(fontSize: 32), + ), + const SizedBox(height: DesignTokens.space2), + Text( + cat.name, style: TextStyle( - fontSize: DesignTokens.fontLg, + fontSize: DesignTokens.fontMd, fontWeight: FontWeight.w600, color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - const SizedBox(height: DesignTokens.space1), + const SizedBox(height: 2), Text( - '根据你的口味偏好推荐', + hasChildren + ? '${cat.children.length} 个子类' + : (cat.count != null && cat.count! > 0 + ? '${cat.count} 道菜谱' + : '浏览'), style: TextStyle( - fontSize: DesignTokens.fontSm, + fontSize: DesignTokens.fontXs, color: isDark - ? DarkDesignTokens.text2 - : DesignTokens.text2, + ? DarkDesignTokens.text3 + : DesignTokens.text3, ), ), + const Spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Icon( + CupertinoIcons.chevron_forward, + size: 16, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ], + ), ], ), ), diff --git a/lib/src/pages/home/recipe_detail_page.dart b/lib/src/pages/home/recipe_detail_page.dart index dbe6bc8..82277f8 100644 --- a/lib/src/pages/home/recipe_detail_page.dart +++ b/lib/src/pages/home/recipe_detail_page.dart @@ -3,7 +3,7 @@ // 更新时间: 2026-04-11 // 名称: recipe_detail_page.dart // 作用: 展示菜谱详细信息 -// 上次更新内容: 修复导入路径和方法调用问题 +// 上次更新内容: 添加热度标签(爆款/热门/受欢迎)+浏览量统计调用 import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -15,6 +15,10 @@ import 'package:mom_kitchen/src/models/recipe/recipe_model.dart'; import 'package:mom_kitchen/src/models/feed_item_model.dart'; import 'package:mom_kitchen/src/services/allergen_checker.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/controllers/shopping_list_controller.dart'; +import 'package:mom_kitchen/src/models/shopping_item_model.dart'; +import 'package:mom_kitchen/src/services/ui/toast_service.dart'; +import 'package:mom_kitchen/src/utils/common_utils.dart'; class RecipeDetailPage extends StatefulWidget { final String recipeId; @@ -35,29 +39,17 @@ class _RecipeDetailPageState extends State { int _likeCount = 0; int _viewCount = 0; - ActionController? _actionController; - FavoritesController? _favoritesController; + late final ActionController _actionController; + late final FavoritesController _favoritesController; @override void initState() { super.initState(); - _initControllers(); + _actionController = Get.find(); + _favoritesController = Get.find(); _loadRecipe(); } - void _initControllers() { - try { - _actionController = Get.find(); - } catch (_) { - _actionController = Get.put(ActionController()); - } - try { - _favoritesController = Get.find(); - } catch (_) { - _favoritesController = Get.put(FavoritesController()); - } - } - Future _loadRecipe() async { try { final recipeId = int.tryParse(widget.recipeId) ?? 0; @@ -83,8 +75,8 @@ class _RecipeDetailPageState extends State { } void _checkFavorite() { - if (_favoritesController != null && _recipe != null) { - final isFav = _favoritesController!.isFavorited(_recipe!.id); + if (_recipe != null) { + final isFav = _favoritesController.isFavorited(_recipe!.id); if (mounted) { setState(() { _isFavorite = isFav; @@ -94,15 +86,15 @@ class _RecipeDetailPageState extends State { } void _recordView() { - if (_actionController != null && _recipe != null) { - _actionController!.reportView(id: _recipe!.id, type: 'recipe'); + if (_recipe != null) { + _actionController.reportView(id: _recipe!.id, type: 'recipe'); } } void _toggleFavorite() { - if (_favoritesController != null && _recipe != null) { + if (_recipe != null) { final feedItem = FeedItemModel.fromRecipe(_recipe!); - _favoritesController!.toggleFavorite(feedItem); + _favoritesController.toggleFavorite(feedItem); setState(() { _isFavorite = !_isFavorite; }); @@ -178,12 +170,22 @@ class _RecipeDetailPageState extends State { : DesignTokens.background, ), child: ListView( + padding: const EdgeInsets.only(bottom: DesignTokens.space5), children: [ _buildHeader(), _buildInfo(), + _buildStatistics(isDark), + _buildTags(isDark), + _buildMetaInfo(isDark), _buildIngredients(), + _buildCategorizedIngredients(isDark), + _buildAllergenWarning(isDark), + _buildApiAllergens(isDark), + _buildScalerButton(isDark), _buildSteps(), _buildNutritionInfo(), + _buildNutritionItems(isDark), + _buildTimeInfo(isDark), _buildActions(), ], ), @@ -270,6 +272,31 @@ class _RecipeDetailPageState extends State { '$_likeCount', style: const TextStyle(color: Colors.white70), ), + if (_viewCount >= 1000 || _likeCount >= 50) ...[ + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: DesignTokens.orange.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _viewCount >= 5000 + ? '🔥 爆款' + : _likeCount >= 100 + ? '❤️ 受欢迎' + : '📈 热门', + style: const TextStyle( + color: CupertinoColors.white, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + ], ], ), ], @@ -327,6 +354,442 @@ class _RecipeDetailPageState extends State { ); } + Widget _buildStatistics(bool isDark) { + final stats = _recipe?.statistics; + 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: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatItem( + '👁️', + '${stats?.views ?? _viewCount}', + '浏览', + isDark, + ), + _buildStatItem('❤️', '${stats?.likes ?? _likeCount}', '点赞', isDark), + _buildStatItem('⭐', '${stats?.recommends ?? 0}', '推荐', isDark), + ], + ), + ), + ); + } + + Widget _buildStatItem(String emoji, String value, String label, bool isDark) { + return Column( + children: [ + Text(emoji, style: const TextStyle(fontSize: 20)), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ); + } + + Widget _buildTags(bool isDark) { + if (_recipe?.tags.isEmpty ?? true) return const SizedBox(); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + child: Wrap( + spacing: DesignTokens.space2, + runSpacing: DesignTokens.space1, + children: _recipe!.tags.map((tag) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.primary : DesignTokens.primary) + .withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '#${tag.name}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.primary : DesignTokens.primary, + fontWeight: FontWeight.w500, + ), + ), + ); + }).toList(), + ), + ); + } + + Widget _buildMetaInfo(bool isDark) { + final meta = _recipe?.meta; + if (meta == null) return const SizedBox(); + + final items = >[]; + if (meta.process != null) items.add(MapEntry('🍳 做法', meta.process!)); + if (meta.taste != null) items.add(MapEntry('👅 口味', meta.taste!)); + if (meta.difficulty != null) items.add(MapEntry('📊 难度', meta.difficulty!)); + if (meta.time != null) items.add(MapEntry('⏱️ 时间', meta.time!)); + if (meta.eatingTime.isNotEmpty) { + items.add(MapEntry('🍽️ 用餐时段', meta.eatingTime.join('、'))); + } + + 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: items.map((entry) { + return Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space1), + child: Row( + children: [ + SizedBox( + width: 80, + child: Text( + entry.key, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded( + child: Text( + entry.value, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ); + } + + Widget _buildCategorizedIngredients(bool isDark) { + final catIng = _recipe?.categorizedIngredients; + if (catIng == null || catIng.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: [ + if (catIng.main.isNotEmpty) ...[ + _buildIngredientCategory('🥩 主料', catIng.main, isDark), + const SizedBox(height: DesignTokens.space2), + ], + if (catIng.auxiliary.isNotEmpty) ...[ + _buildIngredientCategory('🥬 辅料', catIng.auxiliary, isDark), + const SizedBox(height: DesignTokens.space2), + ], + if (catIng.seasoning.isNotEmpty) + _buildIngredientCategory('🧂 调料', catIng.seasoning, isDark), + ], + ), + ), + ); + } + + Widget _buildIngredientCategory( + String title, + List items, + bool isDark, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space1), + ...items.map( + (ing) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Expanded( + child: Text( + ing.name, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + ), + Text( + ing.displayAmount, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildApiAllergens(bool isDark) { + if (_recipe?.allergens.isEmpty ?? true) 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: DesignTokens.orange.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all(color: DesignTokens.orange.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + const Text('⚠️', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '过敏原提示', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: DesignTokens.orange, + ), + ), + const SizedBox(height: 2), + Text( + _recipe!.allergens.join('、'), + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildNutritionItems(bool isDark) { + final items = _recipe?.nutrition?.items; + if (items == null || 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: [ + Text( + '🧬 详细营养成分', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space2), + ...items.map( + (item) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Expanded( + child: Text( + item.name, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ), + Text( + item.displayValue, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.primary + : DesignTokens.primary, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildTimeInfo(bool isDark) { + final hasCreated = _recipe?.createdAt != null; + final hasUpdated = _recipe?.updatedAt != null; + final hasCategory = _recipe?.categoryName != null; + final hasCode = _recipe?.hasCode ?? false; + + if (!hasCreated && !hasUpdated && !hasCategory && !hasCode) { + 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: [ + if (hasCategory) + _buildTimeRow('📂 分类', _recipe!.categoryName!, isDark), + if (hasCode) _buildTimeRow('🔢 编码', _recipe!.code!, isDark), + if (hasCreated) _buildTimeRow('📅 创建', _recipe!.createdAt!, isDark), + if (hasUpdated) _buildTimeRow('🔄 更新', _recipe!.updatedAt!, isDark), + ], + ), + ), + ); + } + + Widget _buildTimeRow(String label, String value, bool isDark) { + return Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space1), + child: Row( + children: [ + SizedBox( + width: 80, + child: Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ), + ], + ), + ); + } + Widget _buildIngredients() { if (_recipe!.ingredients == null || _recipe!.ingredients!.isEmpty) { return const SizedBox(); @@ -382,6 +845,91 @@ class _RecipeDetailPageState extends State { ); } + Widget _buildScalerButton(bool isDark) { + return const SizedBox.shrink(); + } + + Widget _buildAllergenWarning(bool isDark) { + if (_recipe?.ingredients == null || _recipe!.ingredients!.isEmpty) { + return const SizedBox(); + } + + final ingredientText = _recipe!.ingredients! + .map((i) => '${i.name} ${i.amount ?? ''} ${i.unit ?? ''}') + .join(' '); + final detectedAllergens = _allergenChecker.checkAllergens(ingredientText); + + if (detectedAllergens.isEmpty) return const SizedBox(); + + final warningMsg = _allergenChecker.generateWarningMessage( + detectedAllergens, + ); + final substitutions = _allergenChecker.getAllergenSubstitutions( + detectedAllergens, + ); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: DesignTokens.orange.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all(color: DesignTokens.orange.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('⚠️', style: TextStyle(fontSize: 18)), + const SizedBox(width: 8), + Expanded( + child: Text( + warningMsg, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: DesignTokens.orange, + height: 1.4, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + ...substitutions.entries.map((entry) { + return Padding( + padding: const EdgeInsets.only(left: 26, bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '🔄 ', + style: TextStyle(fontSize: DesignTokens.fontXs), + ), + Expanded( + child: Text( + '${entry.key} → 可替换为:${entry.value.join('、')}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + height: 1.3, + ), + ), + ), + ], + ), + ); + }), + ], + ), + ), + ); + } + Widget _buildSteps() { if (_recipe!.content == null || _recipe!.content!.isEmpty) { return const SizedBox(); @@ -489,65 +1037,329 @@ class _RecipeDetailPageState extends State { } Widget _buildActions() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - CupertinoButton( - onPressed: () { - setState(() { - _likeCount++; - }); - _actionController?.likeItem(id: _recipe!.id, type: 'recipe'); - }, - child: Column( - children: [ - const Icon(CupertinoIcons.heart, size: 24), - const SizedBox(height: 4), - Text('$_likeCount'), - ], - ), + final isLiked = _actionController.isLiked(_recipe!.id); + final isRecommended = _actionController.isRecommended(_recipe!.id); + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space3, + ), + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: DesignTokens.text3.withValues(alpha: 0.1), + width: 0.5, ), - CupertinoButton( - onPressed: () { - _actionController?.recommendItem(id: _recipe!.id, type: 'recipe'); - Get.snackbar('成功', '推荐成功'); - }, - child: const Column( - children: [ - Icon(CupertinoIcons.star, size: 24), - SizedBox(height: 4), - Text('推荐'), - ], + ), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildActionButton( + icon: isLiked ? CupertinoIcons.heart_fill : CupertinoIcons.heart, + label: '$_likeCount', + color: isLiked ? DesignTokens.red : null, + onTap: () { + final wasLiked = _actionController.isLiked(_recipe!.id); + setState(() { + _likeCount += wasLiked ? -1 : 1; + }); + _actionController.likeItem(id: _recipe!.id, type: 'recipe'); + }, ), - ), - CupertinoButton( - onPressed: () { - Get.snackbar('提示', '烹饪笔记功能开发中'); - }, - child: const Column( - children: [ - Icon(CupertinoIcons.pencil, size: 24), - SizedBox(height: 4), - Text('笔记'), - ], + const SizedBox(width: DesignTokens.space2), + _buildActionButton( + icon: isRecommended + ? CupertinoIcons.star_fill + : CupertinoIcons.star, + label: isRecommended ? '已推荐' : '推荐', + color: isRecommended ? DesignTokens.orange : null, + onTap: () { + _actionController.recommendItem( + id: _recipe!.id, + type: 'recipe', + ); + setState(() {}); + final wasRecommended = _actionController.isRecommended( + _recipe!.id, + ); + ToastService.show(message: wasRecommended ? '已取消推荐' : '⭐ 推荐成功'); + }, ), - ), - CupertinoButton( - onPressed: () { - Get.snackbar('提示', '已添加到购物清单'); - }, - child: const Column( - children: [ - Icon(CupertinoIcons.cart, size: 24), - SizedBox(height: 4), - Text('购物'), - ], + const SizedBox(width: DesignTokens.space2), + _buildActionButton( + icon: CupertinoIcons.star_circle, + label: '评分', + color: null, + onTap: _showRatingDialog, ), - ), - ], + const SizedBox(width: DesignTokens.space2), + _buildActionButton( + icon: CupertinoIcons.share, + label: '分享', + color: null, + onTap: _shareRecipe, + ), + const SizedBox(width: DesignTokens.space2), + _buildActionButton( + icon: CupertinoIcons.pencil, + label: '笔记', + color: null, + onTap: () { + Get.toNamed( + '/cooking-note', + arguments: { + 'recipeId': widget.recipeId, + 'recipeTitle': _recipe?.title ?? '', + }, + ); + }, + ), + const SizedBox(width: DesignTokens.space2), + _buildActionButton( + icon: CupertinoIcons.cart, + label: '购物', + color: null, + onTap: _addToShoppingList, + ), + const SizedBox(width: DesignTokens.space2), + _buildActionButton( + icon: CupertinoIcons.arrow_up_arrow_down, + label: '缩放', + color: null, + onTap: () { + final ingredients = _recipe!.ingredients?.map((ing) { + return { + 'name': ing.name, + 'amount': ing.amount ?? '', + 'unit': ing.unit ?? '', + }; + }).toList(); + if (ingredients != null && ingredients.isNotEmpty) { + Get.toNamed( + '/serving-scaler', + arguments: {'ingredients': ingredients, 'servings': 4}, + ); + } + }, + ), + ], + ), ), ); } + + Widget _buildActionButton({ + required IconData icon, + required String label, + required Color? color, + required VoidCallback onTap, + }) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + constraints: const BoxConstraints(minWidth: 56), + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space2, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 22, + color: + color ?? + (isDark ? DarkDesignTokens.text2 : DesignTokens.text2), + ), + const SizedBox(height: 2), + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w500, + color: + color ?? + (isDark ? DarkDesignTokens.text2 : DesignTokens.text2), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } + + void _showRatingDialog() { + int selectedScore = 5; + showCupertinoModalPopup( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setModalState) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: CupertinoTheme.brightnessOf(context) == Brightness.dark + ? DarkDesignTokens.background + : DesignTokens.background, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(DesignTokens.radiusLg), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CupertinoButton( + child: const Text('取消'), + onPressed: () => Navigator.pop(ctx), + ), + Text( + '⭐ 评分', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: DesignTokens.text1, + ), + ), + CupertinoButton( + child: const Text('提交'), + onPressed: () { + _actionController.recommendItem( + id: _recipe!.id, + type: 'recipe', + score: selectedScore, + ); + Navigator.pop(ctx); + ToastService.show(message: '⭐ 评分 $selectedScore 星成功'); + }, + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(5, (index) { + final starIndex = index + 1; + return GestureDetector( + onTap: () { + setModalState(() { + selectedScore = starIndex; + }); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Icon( + starIndex <= selectedScore + ? CupertinoIcons.star_fill + : CupertinoIcons.star, + size: 40, + color: starIndex <= selectedScore + ? DesignTokens.orange + : DesignTokens.text3, + ), + ), + ); + }), + ), + const SizedBox(height: DesignTokens.space2), + Text( + _ratingLabel(selectedScore), + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space4), + ], + ), + ); + }, + ); + }, + ); + } + + String _ratingLabel(int score) { + switch (score) { + case 1: + return '😕 不太好吃'; + case 2: + return '😐 一般般'; + case 3: + return '🙂 还不错'; + case 4: + return '😊 很好吃'; + case 5: + return '🤩 超级美味'; + default: + return ''; + } + } + + void _addToShoppingList() { + if (_recipe?.ingredients == null || _recipe!.ingredients!.isEmpty) { + ToastService.show(message: '该菜谱暂无食材信息 📋'); + return; + } + try { + final controller = Get.find(); + final recipeId = int.tryParse(widget.recipeId) ?? 0; + final items = _recipe!.ingredients!.map((ing) { + return ShoppingItemModel( + name: ing.name, + amount: ing.amount, + unit: ing.unit, + category: ing.category, + recipeId: recipeId, + ); + }).toList(); + controller.addItemsFromRecipe(recipeId, _recipe!.title ?? '', items); + } catch (e) { + ToastService.show(message: '添加到购物清单失败 😢'); + } + } + + void _shareRecipe() { + if (_recipe == null) return; + + final ingredients = + _recipe!.ingredients + ?.map((i) { + final amount = i.amount ?? ''; + final unit = i.unit ?? ''; + return '• ${i.name}${amount.isNotEmpty ? ' $amount' : ''}${unit.isNotEmpty ? unit : ''}'; + }) + .join('\n') ?? + ''; + + final shareText = StringBuffer(); + shareText.writeln('🍳 ${_recipe!.title}'); + shareText.writeln(''); + if (ingredients.isNotEmpty) { + shareText.writeln('📋 食材:'); + shareText.writeln(ingredients); + shareText.writeln(''); + } + if (_recipe!.content != null && _recipe!.content!.isNotEmpty) { + shareText.writeln('👩‍🍳 做法:'); + shareText.writeln(_recipe!.content); + } + shareText.writeln(''); + shareText.write('— 来自 妈妈厨房 App 🍳'); + + CommonUtils.shareContent( + shareText.toString(), + subject: '🍳 ${_recipe!.title}', + ); + } } diff --git a/lib/src/pages/home/search_page.dart b/lib/src/pages/home/search_page.dart index 220234d..54bd842 100644 --- a/lib/src/pages/home/search_page.dart +++ b/lib/src/pages/home/search_page.dart @@ -10,6 +10,7 @@ import 'package:flutter/material.dart' show Colors; import 'package:get/get.dart'; import '../../controllers/search_controller.dart'; import '../../config/design_tokens.dart'; +import '../../models/recipe/recipe_model.dart'; import 'recipe_detail_page.dart'; class SearchPage extends StatefulWidget { @@ -374,60 +375,190 @@ class _SearchPageState extends State { } Widget _buildEmptyView(bool isDark) { - return Center( - child: Padding( - padding: const EdgeInsets.all(DesignTokens.space6), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) - .withValues(alpha: 0.08), - shape: BoxShape.circle, - ), - child: Icon( - CupertinoIcons.search, - size: 40, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + return SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space6, + ), + child: Column( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: Icon( + CupertinoIcons.search, + size: 40, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + const SizedBox(height: DesignTokens.space4), + Text( + '未找到"${_searchController.searchQuery.value}"相关菜谱', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: DesignTokens.space2), + Text( + '试试其他关键词,如"红烧肉"、"糖醋排骨"', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space5), + SizedBox( + width: 200, + height: 44, + child: CupertinoButton.filled( + borderRadius: BorderRadius.circular(22), + onPressed: () { + _textEditingController.clear(); + _searchController.clearResults(); + _focusNode.requestFocus(); + }, + child: const Text( + '重新搜索', + style: TextStyle(fontWeight: FontWeight.w500), ), ), - const SizedBox(height: DesignTokens.space4), - Text( - '未找到相关菜谱', - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w500, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - const SizedBox(height: DesignTokens.space2), - Text( - '试试其他关键词,如"红烧肉"、"糖醋排骨"', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - const SizedBox(height: DesignTokens.space5), - SizedBox( - width: 200, - height: 44, - child: CupertinoButton.filled( - borderRadius: BorderRadius.circular(22), - onPressed: () { - _textEditingController.clear(); - _searchController.clearResults(); - _focusNode.requestFocus(); - }, - child: const Text( - '重新搜索', - style: TextStyle(fontWeight: FontWeight.w500), + ), + Obx(() { + if (!_searchController.hasSimilarResults.value || + _searchController.similarResults.isEmpty) { + return const SizedBox(); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: DesignTokens.space6), + Row( + children: [ + Icon( + CupertinoIcons.lightbulb, + size: 18, + color: isDark + ? DarkDesignTokens.primary + : DesignTokens.primary, + ), + const SizedBox(width: 6), + Text( + '相似推荐', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.primary + : DesignTokens.primary, + ), + ), + ], ), + const SizedBox(height: DesignTokens.space3), + ..._searchController.similarResults.map( + (recipe) => _buildSimilarItem(recipe, isDark), + ), + ], + ); + }), + ], + ), + ); + } + + Widget _buildSimilarItem(RecipeModel recipe, bool isDark) { + return GestureDetector( + onTap: () { + Get.toNamed('/recipe-detail', arguments: '${recipe.id}'); + }, + child: Container( + margin: const EdgeInsets.only(bottom: DesignTokens.space2), + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.1), + ), + ), + child: Row( + children: [ + if (recipe.cover != null && recipe.cover!.isNotEmpty) + ClipRRect( + borderRadius: DesignTokens.borderRadiusSm, + child: Image.network( + recipe.cover!, + width: 48, + height: 48, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + width: 48, + height: 48, + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.1) + : DesignTokens.text3.withValues(alpha: 0.05), + child: const Icon(CupertinoIcons.photo, size: 20), + ), + ), + ) + else + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.1) + : DesignTokens.text3.withValues(alpha: 0.05), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: const Icon(CupertinoIcons.photo, size: 20), ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recipe.title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (recipe.intro != null && recipe.intro!.isNotEmpty) + Text( + recipe.intro!, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Icon( + CupertinoIcons.chevron_forward, + size: 16, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, ), ], ), diff --git a/lib/src/pages/profile/favorites_page.dart b/lib/src/pages/profile/favorites_page.dart index cdc61ae..2cbd761 100644 --- a/lib/src/pages/profile/favorites_page.dart +++ b/lib/src/pages/profile/favorites_page.dart @@ -1,12 +1,15 @@ /* * 文件: favorites_page.dart * 名称: 收藏页面 - * 作用: iOS 26 风格的收藏页面,使用 FavoritesController 展示收藏内容 + * 作用: iOS 26 Liquid Glass 风格的收藏页面,使用 FavoritesController 展示收藏内容 * 更新: 2026-04-10 添加工具入口Bar * 更新: 2026-04-11 修复GetX报错,重构为StatefulWidget * 更新: 2026-04-11 移动到pages根目录 + * 更新: 2026-04-11 简化ToolsController获取(已全局注册,移除防御性put) + * 更新: 2026-04-11 iOS 26 Liquid Glass 风格重构 */ +import 'dart:ui'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -24,34 +27,13 @@ class FavoritesPage extends StatefulWidget { class _FavoritesPageState extends State { late final FavoritesController _favoritesController; - ToolsController? _toolsController; + late final ToolsController _toolsController; @override void initState() { super.initState(); _favoritesController = Get.find(); - _initToolsController(); - } - - void _initToolsController() { - try { - _toolsController = Get.find(); - } catch (e) { - debugPrint('ToolsController not found, will be created lazily: $e'); - _toolsController = null; - } - } - - ToolsController _getOrCreateToolsController() { - if (_toolsController != null) { - return _toolsController!; - } - try { - _toolsController = Get.find(); - } catch (_) { - _toolsController = Get.put(ToolsController(), permanent: true); - } - return _toolsController!; + _toolsController = Get.find(); } @override @@ -108,24 +90,7 @@ class _FavoritesPageState extends State { Obx(() { final count = _favoritesController.count; if (count == 0) return const SizedBox.shrink(); - return Container( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space2, - vertical: DesignTokens.space1, - ), - decoration: BoxDecoration( - color: DesignTokens.primaryLight, - borderRadius: DesignTokens.borderRadiusSm, - ), - child: Text( - '$count', - style: const TextStyle( - fontSize: DesignTokens.fontXs, - fontWeight: FontWeight.w600, - color: DesignTokens.primary, - ), - ), - ); + return _buildGlassChip('$count', isDark, highlight: true); }), const SizedBox(width: DesignTokens.space3), Obx(() { @@ -149,14 +114,47 @@ class _FavoritesPageState extends State { ); } - Widget _buildToolsBar(bool isDark) { - final toolsController = _toolsController; - if (toolsController == null) { - return const SizedBox.shrink(); - } + Widget _buildGlassChip(String text, bool isDark, {bool highlight = false}) { + return ClipRRect( + borderRadius: DesignTokens.borderRadiusSm, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: highlight + ? DesignTokens.primaryLight.withValues(alpha: 0.7) + : (isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.7)), + borderRadius: DesignTokens.borderRadiusSm, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.15), + ), + ), + child: Text( + text, + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: highlight + ? DesignTokens.primary + : (isDark ? DarkDesignTokens.text1 : DesignTokens.text1), + ), + ), + ), + ), + ); + } + Widget _buildToolsBar(bool isDark) { return Obx(() { - final tools = toolsController.frequentTools; + final tools = _toolsController.frequentTools; if (tools.isEmpty) { return const SizedBox.shrink(); } @@ -173,7 +171,7 @@ class _FavoritesPageState extends State { if (index == tools.length) { return _buildMoreToolsCard(isDark); } - return _buildToolShortcut(tools[index], toolsController, isDark); + return _buildToolShortcut(tools[index], _toolsController, isDark); }, ), ); @@ -187,21 +185,104 @@ class _FavoritesPageState extends State { ) { return GestureDetector( onTap: () => controller.openTool(tool), - child: Container( - width: 72, - padding: const EdgeInsets.symmetric(vertical: DesignTokens.space2), - 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: ClipRRect( + borderRadius: DesignTokens.borderRadiusMd, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: Container( + width: 72, + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space2), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.75), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.12), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: + (isDark + ? DarkDesignTokens.primary + : DesignTokens.primary) + .withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Text( + tool.icon, + style: const TextStyle(fontSize: 24), + ), + ), + ), + Positioned( + top: 0, + right: 0, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: tool.needsNetwork + ? DesignTokens.green + : DesignTokens.primary, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + tool.name, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), ), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Stack( + ), + ); + } + + Widget _buildMoreToolsCard(bool isDark) { + return GestureDetector( + onTap: () => Get.toNamed('/tools'), + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusMd, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: Container( + width: 72, + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space2), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.primary.withValues(alpha: 0.08) + : DesignTokens.primaryLight.withValues(alpha: 0.7), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: + (isDark ? DarkDesignTokens.primary : DesignTokens.primary) + .withValues(alpha: 0.25), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: 44, @@ -211,90 +292,27 @@ class _FavoritesPageState extends State { (isDark ? DarkDesignTokens.primary : DesignTokens.primary) - .withValues(alpha: 0.1), + .withValues(alpha: 0.15), borderRadius: DesignTokens.borderRadiusMd, ), - child: Center( - child: Text( - tool.icon, - style: const TextStyle(fontSize: 24), - ), + child: const Center( + child: Text('🛠️', style: TextStyle(fontSize: 24)), ), ), - Positioned( - top: 0, - right: 0, - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: tool.needsNetwork - ? DesignTokens.green - : DesignTokens.primary, - shape: BoxShape.circle, - ), + const SizedBox(height: 6), + Text( + '更多', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.primary + : DesignTokens.primary, ), ), ], ), - const SizedBox(height: 6), - Text( - tool.name, - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ); - } - - Widget _buildMoreToolsCard(bool isDark) { - return GestureDetector( - onTap: () => Get.toNamed('/tools'), - child: Container( - width: 72, - padding: const EdgeInsets.symmetric(vertical: DesignTokens.space2), - decoration: BoxDecoration( - color: isDark - ? DarkDesignTokens.primary.withValues(alpha: 0.1) - : DesignTokens.primaryLight, - borderRadius: DesignTokens.borderRadiusMd, - border: Border.all( - color: (isDark ? DarkDesignTokens.primary : DesignTokens.primary) - .withValues(alpha: 0.3), ), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: - (isDark ? DarkDesignTokens.primary : DesignTokens.primary) - .withValues(alpha: 0.15), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: const Center( - child: Text('🛠️', style: TextStyle(fontSize: 24)), - ), - ), - const SizedBox(height: 6), - Text( - '更多', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark ? DarkDesignTokens.primary : DesignTokens.primary, - ), - ), - ], - ), ), ); } @@ -321,37 +339,45 @@ class _FavoritesPageState extends State { Widget _buildSortButton(bool isDark) { return GestureDetector( onTap: () => _showSortSheet(isDark), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space3, - vertical: DesignTokens.space2, - ), - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - borderRadius: DesignTokens.borderRadiusMd, - border: Border.all( - color: isDark - ? DarkDesignTokens.text3.withValues(alpha: 0.2) - : DesignTokens.text3.withValues(alpha: 0.15), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - CupertinoIcons.arrow_up_arrow_down, - size: 14, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusMd, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, ), - const SizedBox(width: DesignTokens.space1), - Text( - _getSortLabel(_favoritesController.sortMode.value), - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.75), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.12), ), ), - ], + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.arrow_up_arrow_down, + size: 14, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + const SizedBox(width: DesignTokens.space1), + Text( + _getSortLabel(_favoritesController.sortMode.value), + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ), ), ), ); @@ -370,37 +396,45 @@ class _FavoritesPageState extends State { padding: const EdgeInsets.only(right: DesignTokens.space2), child: GestureDetector( onTap: () => _favoritesController.setCategory(cat), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space3, - vertical: DesignTokens.space2, - ), - decoration: BoxDecoration( - color: isSelected - ? DesignTokens.primary - : (isDark - ? DarkDesignTokens.card - : DesignTokens.card), - borderRadius: DesignTokens.borderRadiusMd, - border: Border.all( - color: isSelected - ? DesignTokens.primary - : (isDark - ? DarkDesignTokens.text3.withValues( - alpha: 0.2, - ) - : DesignTokens.text3.withValues(alpha: 0.15)), - ), - ), - child: Text( - cat == 'all' ? '全部' : cat, - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isSelected - ? CupertinoColors.white - : (isDark - ? DarkDesignTokens.text2 - : DesignTokens.text2), + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusMd, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.primary.withValues(alpha: 0.85) + : (isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues( + alpha: 0.75, + )), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isSelected + ? DesignTokens.primary + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues( + alpha: 0.12, + )), + ), + ), + child: Text( + cat == 'all' ? '全部' : cat, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isSelected + ? CupertinoColors.white + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + ), ), ), ), @@ -449,55 +483,62 @@ class _FavoritesPageState extends State { } Widget _buildEditBottomBar(bool isDark) { - return Container( - padding: const EdgeInsets.all(DesignTokens.space4), - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - border: Border( - top: BorderSide( + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( color: isDark - ? DarkDesignTokens.text3.withValues(alpha: 0.2) - : DesignTokens.text3.withValues(alpha: 0.15), + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.85), + border: Border( + top: BorderSide( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.12), + ), + ), + ), + child: Row( + children: [ + CupertinoButton( + padding: EdgeInsets.zero, + onPressed: _favoritesController.hasSelection + ? _favoritesController.deselectAll + : _favoritesController.selectAll, + child: Text( + _favoritesController.hasSelection ? '取消全选' : '全选', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: DesignTokens.primary, + ), + ), + ), + const Spacer(), + Obx( + () => Text( + '已选 ${_favoritesController.selectedCount} 项', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ), + const SizedBox(width: DesignTokens.space4), + CupertinoButton.filled( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + onPressed: _favoritesController.hasSelection + ? () => _confirmDelete() + : null, + child: const Text('删除'), + ), + ], ), ), ), - child: Row( - children: [ - CupertinoButton( - padding: EdgeInsets.zero, - onPressed: _favoritesController.hasSelection - ? _favoritesController.deselectAll - : _favoritesController.selectAll, - child: Text( - _favoritesController.hasSelection ? '取消全选' : '全选', - style: TextStyle( - fontSize: DesignTokens.fontMd, - color: DesignTokens.primary, - ), - ), - ), - const Spacer(), - Obx( - () => Text( - '已选 ${_favoritesController.selectedCount} 项', - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - ), - const SizedBox(width: DesignTokens.space4), - CupertinoButton.filled( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - ), - onPressed: _favoritesController.hasSelection - ? () => _confirmDelete() - : null, - child: const Text('删除'), - ), - ], - ), ); } @@ -524,40 +565,61 @@ class _FavoritesPageState extends State { Widget _buildEmptyState(bool isDark) { return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 80, - height: 80, + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusXl, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + width: 200, + padding: const EdgeInsets.all(DesignTokens.space5), decoration: BoxDecoration( - color: DesignTokens.primaryLight, + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.7), borderRadius: DesignTokens.borderRadiusXl, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.12), + ), ), - child: const Icon( - CupertinoIcons.heart, - size: 36, - color: DesignTokens.primary, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: DesignTokens.primaryLight.withValues(alpha: 0.6), + borderRadius: DesignTokens.borderRadiusLg, + ), + child: const Icon( + CupertinoIcons.heart, + size: 32, + color: DesignTokens.primary, + ), + ), + const SizedBox(height: DesignTokens.space3), + Text( + '收藏夹是空的', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space1), + Text( + '浏览菜谱时点击 🔖 即可收藏', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], ), ), - const SizedBox(height: DesignTokens.space4), - Text( - '收藏夹是空的', - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w500, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - const SizedBox(height: DesignTokens.space2), - Text( - '浏览菜谱时点击 🔖 即可收藏', - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, - ), - ), - ], + ), ), ); } @@ -569,7 +631,7 @@ class _FavoritesPageState extends State { vertical: DesignTokens.space2, ), itemCount: favorites.length, - separatorBuilder: (_, _) => + separatorBuilder: (_, __) => const SizedBox(height: DesignTokens.space2 + 2), itemBuilder: (context, index) { final item = favorites[index]; @@ -589,112 +651,126 @@ class _FavoritesPageState extends State { : () { Get.toNamed('/recipe-detail', arguments: '${item.id}'); }, - child: Container( - padding: const EdgeInsets.all(DesignTokens.space3), - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - borderRadius: DesignTokens.borderRadiusLg, - boxShadow: DesignTokens.shadowsSm, - border: isEditMode && isSelected - ? Border.all(color: DesignTokens.primary, width: 2) - : null, - ), - child: Row( - children: [ - if (isEditMode) ...[ - Container( - width: 24, - height: 24, - margin: const EdgeInsets.only(right: DesignTokens.space3), - decoration: BoxDecoration( - color: isSelected - ? DesignTokens.primary - : Colors.transparent, - borderRadius: DesignTokens.borderRadiusSm, - border: Border.all( - color: isSelected - ? DesignTokens.primary - : (isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3), - width: 2, - ), - ), - child: isSelected - ? const Icon( - CupertinoIcons.checkmark_alt, - size: 14, - color: CupertinoColors.white, - ) - : null, - ), - ], - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: DesignTokens.primaryLight, - borderRadius: DesignTokens.borderRadiusMd, - ), - child: const Icon( - CupertinoIcons.book, - size: 24, - color: DesignTokens.primary, + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusLg, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isEditMode && isSelected + ? DesignTokens.primary.withValues(alpha: 0.15) + : (isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.75)), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isEditMode && isSelected + ? DesignTokens.primary.withValues(alpha: 0.5) + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.1)), + width: isEditMode && isSelected ? 1.5 : 0.5, ), ), - const SizedBox(width: DesignTokens.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.title ?? '', - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, + child: Row( + children: [ + if (isEditMode) ...[ + Container( + width: 24, + height: 24, + margin: const EdgeInsets.only(right: DesignTokens.space3), + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.primary + : Colors.transparent, + borderRadius: DesignTokens.borderRadiusSm, + border: Border.all( + color: isSelected + ? DesignTokens.primary + : (isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3), + width: 2, + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: DesignTokens.space1), - Text( - item.intro ?? '', - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark - ? DarkDesignTokens.text2 - : DesignTokens.text2, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: isSelected + ? const Icon( + CupertinoIcons.checkmark_alt, + size: 14, + color: CupertinoColors.white, + ) + : null, ), ], - ), - ), - if (!isEditMode) ...[ - const SizedBox(width: DesignTokens.space2), - GestureDetector( - onTap: () => _favoritesController.removeFavorite(item.id), - behavior: HitTestBehavior.opaque, - child: Container( - width: 36, - height: 36, + Container( + width: 56, + height: 56, decoration: BoxDecoration( - color: DesignTokens.red.withValues(alpha: 0.1), - borderRadius: DesignTokens.borderRadiusSm, + color: DesignTokens.primaryLight.withValues(alpha: 0.5), + borderRadius: DesignTokens.borderRadiusMd, ), child: const Icon( - CupertinoIcons.heart_fill, - size: 18, - color: DesignTokens.red, + CupertinoIcons.book, + size: 24, + color: DesignTokens.primary, ), ), - ), - ], - ], + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title ?? '', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: DesignTokens.space1), + Text( + item.intro ?? '', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (!isEditMode) ...[ + const SizedBox(width: DesignTokens.space2), + GestureDetector( + onTap: () => _favoritesController.removeFavorite(item.id), + behavior: HitTestBehavior.opaque, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: DesignTokens.red.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: const Icon( + CupertinoIcons.heart_fill, + size: 18, + color: DesignTokens.red, + ), + ), + ), + ], + ], + ), + ), ), ), ); diff --git a/lib/src/pages/profile/nutrition/nutrition_report_page.dart b/lib/src/pages/profile/nutrition/nutrition_report_page.dart index b5f863a..d392096 100644 --- a/lib/src/pages/profile/nutrition/nutrition_report_page.dart +++ b/lib/src/pages/profile/nutrition/nutrition_report_page.dart @@ -1,6 +1,7 @@ // 2026-04-09 | NutritionReportPage | 营养报告页面 | iOS26风格周/月趋势分析 // 2026-04-09 | 初始创建,使用fl_chart展示热量趋势和营养素占比 // 2026-04-10 | 修复卡死问题:添加错误处理+空指针保护 +// 2026-04-11 | 新增餐次分布饼图(早/午/晚/加餐热量占比) import 'package:flutter/cupertino.dart'; import 'package:get/get.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; @@ -94,6 +95,8 @@ class _NutritionReportPageState extends State { const SizedBox(height: DesignTokens.space3), _buildNutritionPieCard(isDark), const SizedBox(height: DesignTokens.space3), + _buildMealTypePieCard(isDark), + const SizedBox(height: DesignTokens.space3), _buildSummaryCard(isDark), const SizedBox(height: DesignTokens.space7), ], @@ -190,6 +193,29 @@ class _NutritionReportPageState extends State { ); } + Widget _buildMealTypePieCard(bool isDark) { + final mealTypeCalories = + _ctrl?.getMealTypeCalories() ?? + {'breakfast': 0, 'lunch': 0, 'dinner': 0, 'snack': 0}; + + return GlassContainer( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppSectionHeader(title: '🍽️ 餐次分布', isDark: isDark), + const SizedBox(height: DesignTokens.space2), + MealTypePieChart( + breakfast: mealTypeCalories['breakfast'] ?? 0, + lunch: mealTypeCalories['lunch'] ?? 0, + dinner: mealTypeCalories['dinner'] ?? 0, + snack: mealTypeCalories['snack'] ?? 0, + ), + ], + ), + ); + } + Widget _buildSummaryCard(bool isDark) { final nutrition = _period == ReportPeriod.weekly ? (_ctrl?.getWeeklyAggregatedNutrition() ?? diff --git a/lib/src/pages/profile/profile_page.dart b/lib/src/pages/profile/profile_page.dart index 0fddcd0..ed80010 100644 --- a/lib/src/pages/profile/profile_page.dart +++ b/lib/src/pages/profile/profile_page.dart @@ -1,8 +1,9 @@ /* * 文件: profile_page.dart * 名称: 我的页面 - * 作用: iOS 26 风格的个人中心,包含首页和设置两个子Tab + * 作用: iOS 26 风格的个人中心,包含首页和设置两个子Tab,支持左右滑动切换 * 更新: 2026-04-09 重构为 Liquid Glass 风格 + * 更新: 2026-04-11 添加左右滑动切换支持 */ import 'package:flutter/cupertino.dart'; @@ -20,6 +21,19 @@ class ProfilePage extends StatefulWidget { class _ProfilePageState extends State { int _selectedTabIndex = 0; + late final PageController _pageController; + + @override + void initState() { + super.initState(); + _pageController = PageController(initialPage: _selectedTabIndex); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -65,14 +79,26 @@ class _ProfilePageState extends State { selectedIndex: _selectedTabIndex, onChanged: (i) { setState(() => _selectedTabIndex = i); + _pageController.animateToPage( + i, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); }, ), ), const SizedBox(height: DesignTokens.space3), Expanded( - child: _selectedTabIndex == 0 - ? const ProfileHomeTab() - : const ProfileSettingsTab(), + child: PageView( + controller: _pageController, + onPageChanged: (i) { + setState(() => _selectedTabIndex = i); + }, + children: const [ + ProfileHomeTab(), + ProfileSettingsTab(), + ], + ), ), ], ), diff --git a/lib/src/pages/profile/profile_settings.dart b/lib/src/pages/profile/profile_settings.dart index 5a7c6d7..a46432b 100644 --- a/lib/src/pages/profile/profile_settings.dart +++ b/lib/src/pages/profile/profile_settings.dart @@ -66,6 +66,12 @@ class ProfileSettingsTab extends StatelessWidget { isDark: isDark, onTap: () => Get.to(() => const HotPage()), ), + _buildTile( + icon: CupertinoIcons.time, + title: '用餐时段 🍽️', + isDark: isDark, + onTap: () => Get.toNamed('/eating-times'), + ), _buildTile( icon: CupertinoIcons.chat_bubble_text, title: '关于', diff --git a/lib/src/pages/profile/settings/personalization_page.dart b/lib/src/pages/profile/settings/personalization_page.dart index a616273..7cce934 100644 --- a/lib/src/pages/profile/settings/personalization_page.dart +++ b/lib/src/pages/profile/settings/personalization_page.dart @@ -38,27 +38,53 @@ class PersonalizationPage extends StatelessWidget { ), CupertinoListSection.insetGrouped( header: const Text('📝 字体大小'), - children: [ - _buildFontSizeItem(controller, themeService), - ], + children: [_buildFontSizeItem(controller, themeService)], ), CupertinoListSection.insetGrouped( header: const Text('🌙 显示模式'), children: [ CupertinoListTile( - title: const Text('深色模式'), + title: const Text('跟随系统'), trailing: CupertinoSwitch( - value: controller.isDarkMode, - onChanged: (_) => controller.toggleDarkMode(), + value: + themeService.darkModeSource.value == + DarkModeSource.system, + onChanged: (v) { + controller.setDarkModeSource( + v + ? DarkModeSource.system + : DarkModeSource.manual, + ); + }, ), ), + if (themeService.darkModeSource.value != + DarkModeSource.system) + CupertinoListTile( + title: const Text('深色模式'), + trailing: CupertinoSwitch( + value: controller.isDarkMode, + onChanged: (_) => controller.toggleDarkMode(), + ), + ), + if (themeService.darkModeSource.value == + DarkModeSource.system) + CupertinoListTile( + title: Text( + '当前: ${themeService.isDarkMode.value ? "深色模式 🌙" : "浅色模式 ☀️"}', + style: TextStyle( + fontSize: 14, + color: themeService.textColor.value.withValues( + alpha: 0.6, + ), + ), + ), + ), ], ), CupertinoListSection.insetGrouped( header: const Text('✨ 动画效果'), - children: [ - _buildAnimationItem(controller, themeService), - ], + children: [_buildAnimationItem(controller, themeService)], ), CupertinoListSection.insetGrouped( header: const Text('🌐 语言'), @@ -122,14 +148,13 @@ class PersonalizationPage extends StatelessWidget { ), CupertinoListSection.insetGrouped( header: const Text('预览'), - children: [ - _buildPreviewItem(themeService), - ], + children: [_buildPreviewItem(themeService)], ), Padding( padding: const EdgeInsets.all(16), child: CupertinoButton.filled( - onPressed: () => _showResetDialog(controller, themeService), + onPressed: () => + _showResetDialog(controller, themeService), child: const Text('恢复默认设置'), ), ), diff --git a/lib/src/pages/profile/settings/preference_page.dart b/lib/src/pages/profile/settings/preference_page.dart index 557cd0e..d8fcae9 100644 --- a/lib/src/pages/profile/settings/preference_page.dart +++ b/lib/src/pages/profile/settings/preference_page.dart @@ -10,6 +10,7 @@ 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_preference_model.dart'; import 'package:mom_kitchen/src/services/ui/theme_service.dart'; class PreferencePage extends StatelessWidget { @@ -108,35 +109,73 @@ class PreferencePage extends StatelessWidget { } return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Wrap( - spacing: 8, - runSpacing: 8, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: categories.map((cat) { - final isSelected = prefController.isCategoryPreferred(cat.id); - return _buildChip( - label: '${cat.icon ?? '📂'} ${cat.name}', - icon: '', - isSelected: isSelected, - themeService: themeService, - onTap: () => prefController.toggleCategory(cat.id), - ); + return _buildCategoryGroup(cat, prefController, themeService); }).toList(), ), ); }); } + Widget _buildCategoryGroup( + PreferenceCategory cat, + PreferenceController prefController, + ThemeService themeService, + ) { + final isSelected = prefController.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), + ), + ), + if (hasChildren) + Padding( + padding: const EdgeInsets.only(left: 20), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: cat.children.map((subCat) { + final isSubSelected = prefController.isCategoryPreferred( + subCat.id, + ); + return _buildChip( + label: '${subCat.displayIcon} ${subCat.name}', + icon: '', + isSelected: isSubSelected, + themeService: themeService, + onTap: () => prefController.toggleCategory(subCat.id), + ); + }).toList(), + ), + ), + const SizedBox(height: 8), + ], + ); + } + Widget _buildTagSection( PreferenceController prefController, ThemeService themeService, ) { return Obx(() { - final prefTags = prefController.preference.value?.preferredTags ?? []; - if (prefTags.isEmpty) { + 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), @@ -149,7 +188,7 @@ class PreferencePage extends StatelessWidget { child: Wrap( spacing: 8, runSpacing: 8, - children: prefTags.map((tag) { + children: availableTags.map((tag) { final isSelected = prefController.isTagPreferred(tag.id); return _buildChip( label: '🏷️ ${tag.name}', diff --git a/lib/src/pages/profile/shopping_list_page.dart b/lib/src/pages/profile/shopping_list_page.dart index 797aaf2..0442e02 100644 --- a/lib/src/pages/profile/shopping_list_page.dart +++ b/lib/src/pages/profile/shopping_list_page.dart @@ -377,28 +377,36 @@ class _ShoppingListPageState extends State { children: [ GestureDetector( onTap: () => _ctrl.toggleChecked(key, item), + behavior: HitTestBehavior.opaque, child: Container( - width: 28, - height: 28, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: item.isChecked ? DesignTokens.green : Colors.transparent, - border: Border.all( + width: 44, + height: 44, + alignment: Alignment.center, + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + shape: BoxShape.circle, color: item.isChecked ? DesignTokens.green - : isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, - width: 2, + : Colors.transparent, + border: Border.all( + color: item.isChecked + ? DesignTokens.green + : isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + width: 2, + ), ), + child: item.isChecked + ? const Icon( + CupertinoIcons.checkmark, + size: 16, + color: CupertinoColors.white, + ) + : null, ), - child: item.isChecked - ? const Icon( - CupertinoIcons.checkmark, - size: 16, - color: CupertinoColors.white, - ) - : null, ), ), const SizedBox(width: DesignTokens.space2), @@ -432,11 +440,14 @@ class _ShoppingListPageState extends State { ), GestureDetector( onTap: () => _showDeleteConfirm(key, item), - child: Padding( - padding: const EdgeInsets.all(DesignTokens.space2), + behavior: HitTestBehavior.opaque, + child: Container( + width: 44, + height: 44, + alignment: Alignment.center, child: Icon( CupertinoIcons.trash, - size: 18, + size: 22, color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, ), ), diff --git a/lib/src/pages/tools/cooking_note_page.dart b/lib/src/pages/tools/cooking_note_page.dart new file mode 100644 index 0000000..cf057c0 --- /dev/null +++ b/lib/src/pages/tools/cooking_note_page.dart @@ -0,0 +1,386 @@ +/* + * 文件: cooking_note_page.dart + * 名称: 烹饪笔记页面 + * 作用: 按菜谱关联的个人笔记,支持增删改查 + * 创建: 2026-04-11 + * 更新: 2026-04-11 初始创建 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/controllers/cooking_note_controller.dart'; +import 'package:mom_kitchen/src/models/cooking_note_model.dart'; +import 'package:mom_kitchen/src/services/ui/toast_service.dart'; + +class CookingNotePage extends StatefulWidget { + final String recipeId; + final String recipeTitle; + + const CookingNotePage({ + super.key, + required this.recipeId, + this.recipeTitle = '', + }); + + @override + State createState() => _CookingNotePageState(); +} + +class _CookingNotePageState extends State { + final CookingNoteController _controller = CookingNoteController.to; + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return CupertinoPageScaffold( + backgroundColor: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + navigationBar: CupertinoNavigationBar( + middle: Text( + '📝 烹饪笔记', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + trailing: CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () => _showAddDialog(isDark), + child: Icon( + CupertinoIcons.add, + color: isDark ? DarkDesignTokens.primary : DesignTokens.primary, + size: 28, + ), + ), + backgroundColor: isDark + ? DarkDesignTokens.background.withValues(alpha: 0.9) + : DesignTokens.background.withValues(alpha: 0.9), + border: null, + ), + child: SafeArea(top: false, child: Obx(() => _buildBody(isDark))), + ); + } + + Widget _buildBody(bool isDark) { + final allNotes = _controller.notes; + final notes = allNotes + .where((note) => note.recipeId == widget.recipeId) + .toList(); + + if (notes.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('📝', style: TextStyle(fontSize: 56)), + const SizedBox(height: DesignTokens.space4), + Text( + '还没有笔记', + style: TextStyle( + fontSize: DesignTokens.fontLg, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space2), + Text( + '点击右上角 + 记录烹饪心得', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + if (widget.recipeTitle.isNotEmpty) ...[ + const SizedBox(height: DesignTokens.space3), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space6, + ), + child: Text( + '📖 ${widget.recipeTitle}', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), + ); + } + + return Column( + children: [ + if (widget.recipeTitle.isNotEmpty) _buildRecipeHeader(isDark), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.all(DesignTokens.space4), + itemCount: notes.length, + separatorBuilder: (_, __) => + const SizedBox(height: DesignTokens.space3), + itemBuilder: (context, index) => + _buildNoteCard(notes[index], isDark), + ), + ), + ], + ); + } + + Widget _buildRecipeHeader(bool isDark) { + return Container( + margin: const EdgeInsets.fromLTRB( + DesignTokens.space4, + DesignTokens.space4, + DesignTokens.space4, + 0, + ), + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.15), + ), + ), + child: Row( + children: [ + const Text('📖', style: TextStyle(fontSize: 20)), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Text( + widget.recipeTitle, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + '${_controller.getNotesByRecipeId(widget.recipeId).length} 条笔记', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ); + } + + Widget _buildNoteCard(CookingNoteModel note, bool isDark) { + return Dismissible( + key: ValueKey(note.id), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: DesignTokens.space5), + decoration: BoxDecoration( + color: DesignTokens.red, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: const Icon(CupertinoIcons.delete, color: CupertinoColors.white), + ), + confirmDismiss: (_) => _confirmDelete(note, isDark), + onDismissed: (_) { + _controller.deleteNote(note.id); + ToastService.show(message: '笔记已删除 🗑️'); + setState(() {}); + }, + child: GestureDetector( + onTap: () => _showEditDialog(note, isDark), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.15), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + note.content, + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + height: 1.5, + ), + maxLines: 5, + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + CupertinoIcons.right_chevron, + size: 14, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + Row( + children: [ + if (note.hasPhoto) ...[ + Icon( + CupertinoIcons.photo, + size: 14, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + const SizedBox(width: DesignTokens.space1), + ], + Text( + note.displayDate.isNotEmpty ? note.displayDate : '刚刚', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Future _confirmDelete(CookingNoteModel note, bool isDark) async { + return await showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('删除笔记'), + content: const Text('确定要删除这条笔记吗?此操作不可撤销。'), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), + ), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () => Navigator.pop(ctx, true), + child: const Text('删除'), + ), + ], + ), + ) ?? + false; + } + + void _showAddDialog(bool isDark) { + final controller = TextEditingController(); + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('📝 添加笔记'), + content: Padding( + padding: const EdgeInsets.only(top: DesignTokens.space3), + child: SizedBox( + height: 120, + child: CupertinoTextField( + controller: controller, + placeholder: '记录你的烹饪心得...', + maxLines: 5, + autofocus: true, + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + ), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () async { + if (controller.text.trim().isNotEmpty) { + final note = CookingNoteModel( + id: DateTime.now().millisecondsSinceEpoch.toString(), + recipeId: widget.recipeId, + content: controller.text.trim(), + createdAt: DateTime.now().toIso8601String(), + ); + await _controller.addNote(note); + ToastService.show(message: '笔记已保存 ✅'); + if (mounted) setState(() {}); + } + if (ctx.mounted) Navigator.pop(ctx); + }, + child: const Text('保存'), + ), + ], + ), + ); + } + + void _showEditDialog(CookingNoteModel note, bool isDark) { + final controller = TextEditingController(text: note.content); + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('✏️ 编辑笔记'), + content: Padding( + padding: const EdgeInsets.only(top: DesignTokens.space3), + child: SizedBox( + height: 120, + child: CupertinoTextField( + controller: controller, + maxLines: 5, + autofocus: true, + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + ), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () async { + if (controller.text.trim().isNotEmpty) { + final updated = note.copyWith(content: controller.text.trim()); + await _controller.updateNote(updated); + ToastService.show(message: '笔记已更新 ✅'); + if (mounted) setState(() {}); + } + if (ctx.mounted) Navigator.pop(ctx); + }, + child: const Text('保存'), + ), + ], + ), + ); + } +} diff --git a/lib/src/pages/tools/cooking_timer_page.dart b/lib/src/pages/tools/cooking_timer_page.dart index 05f2e08..760e374 100644 --- a/lib/src/pages/tools/cooking_timer_page.dart +++ b/lib/src/pages/tools/cooking_timer_page.dart @@ -468,6 +468,27 @@ class _AddStepDialogState extends State<_AddStepDialog> { final TextEditingController _nameController = TextEditingController(); int _minutes = 5; + static const List> _presets = [ + MapEntry('🥚 煮鸡蛋', 10), + MapEntry('🍝 煮面条', 8), + MapEntry('🍚 煮米饭', 20), + MapEntry('🥘 炖汤', 45), + MapEntry('🍳 煎蛋', 3), + MapEntry('🥩 煎牛排', 8), + MapEntry('🍲 炖肉', 60), + MapEntry('🥬 炒青菜', 3), + MapEntry('🧊 焯水', 2), + MapEntry('♨️ 蒸鱼', 12), + MapEntry('🫕 煮粥', 30), + MapEntry('🥟 蒸饺子', 15), + MapEntry('🍞 烤面包', 25), + MapEntry('🥕 炖蔬菜', 15), + MapEntry('🐟 红烧鱼', 20), + MapEntry('🍗 烤鸡翅', 25), + MapEntry('🫔 烙饼', 5), + MapEntry('🥣 煮豆浆', 20), + ]; + @override void dispose() { _nameController.dispose(); @@ -476,9 +497,12 @@ class _AddStepDialogState extends State<_AddStepDialog> { @override Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + return CupertinoAlertDialog( title: const Text('添加步骤'), content: Column( + mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: DesignTokens.space3), CupertinoTextField( @@ -514,6 +538,55 @@ class _AddStepDialogState extends State<_AddStepDialog> { ), ], ), + const SizedBox(height: DesignTokens.space3), + Container( + constraints: const BoxConstraints(maxHeight: 200), + child: SingleChildScrollView( + child: Wrap( + spacing: 6, + runSpacing: 6, + children: _presets.map((preset) { + return GestureDetector( + onTap: () { + setState(() { + _nameController.text = preset.key; + _minutes = preset.value; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: (isDark + ? DarkDesignTokens.primary + : DesignTokens.primary) + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: (isDark + ? DarkDesignTokens.primary + : DesignTokens.primary) + .withValues(alpha: 0.2), + ), + ), + child: Text( + '${preset.key} ${preset.value}min', + style: TextStyle( + fontSize: 12, + color: isDark + ? DarkDesignTokens.primary + : DesignTokens.primary, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + }).toList(), + ), + ), + ), ], ), actions: [ diff --git a/lib/src/pages/tools/eating_times_page.dart b/lib/src/pages/tools/eating_times_page.dart new file mode 100644 index 0000000..bfb1b38 --- /dev/null +++ b/lib/src/pages/tools/eating_times_page.dart @@ -0,0 +1,573 @@ +/* + * 文件: eating_times_page.dart + * 名称: 用餐时段推荐页面 + * 作用: 展示API提供的用餐时段分类数据,支持按时段浏览菜谱 + * 创建: 2026-04-11 + * 更新: 2026-04-11 初始创建,基于eating_times.json数据 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:dio/dio.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/repositories/recipe_repository.dart'; + +class EatingTimeItem { + final int id; + final String name; + final int count; + + const EatingTimeItem({ + required this.id, + required this.name, + required this.count, + }); + + factory EatingTimeItem.fromJson(Map json) { + return EatingTimeItem( + id: (json['id'] is int) ? json['id'] : int.tryParse('${json['id']}') ?? 0, + name: '${json['name'] ?? ''}', + count: (json['count'] is int) + ? json['count'] + : int.tryParse('${json['count']}') ?? 0, + ); + } +} + +class EatingTimesGroup { + final String title; + final String icon; + final List items; + + const EatingTimesGroup({ + required this.title, + required this.icon, + required this.items, + }); +} + +class EatingTimesPage extends StatefulWidget { + const EatingTimesPage({super.key}); + + @override + State createState() => _EatingTimesPageState(); +} + +class _EatingTimesPageState extends State { + final Dio _dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + ), + ); + final RecipeRepository _recipeRepo = RecipeRepository(); + + List _groups = []; + bool _isLoading = true; + String _errorMessage = ''; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + final response = await _dio + .get(ApiConfig.eatingTimesJson) + .timeout(const Duration(seconds: 10)); + + if (response.data == null) { + throw Exception('数据为空'); + } + + final data = response.data as Map; + final groups = []; + + final groupConfigs = [ + ('standard_times', '🌅 标准时段', '🍽️'), + ('combined_times', '🔄 组合时段', '🥘'), + ('frequency_times', '📅 频率时段', '⏰'), + ('method_times', '👨‍🍳 方法时段', '🍳'), + ('other_times', '📋 其他时段', '📌'), + ]; + + for (final config in groupConfigs) { + final key = config.$1; + final title = config.$2; + final icon = config.$3; + final items = data[key]; + if (items is List && items.isNotEmpty) { + groups.add( + EatingTimesGroup( + title: title, + icon: icon, + items: items + .whereType>() + .map((e) => EatingTimeItem.fromJson(e)) + .where((e) => e.count > 0) + .toList(), + ), + ); + } + } + + setState(() { + _groups = groups; + _isLoading = false; + }); + } catch (e) { + debugPrint('EatingTimesPage load error: $e'); + setState(() { + _errorMessage = '加载失败: $e'; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text( + '🍽️ 用餐时段', + style: TextStyle( + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + backgroundColor: isDark + ? DarkDesignTokens.background.withValues(alpha: 0.85) + : DesignTokens.background.withValues(alpha: 0.85), + border: null, + ), + child: SafeArea( + child: _isLoading + ? const Center(child: CupertinoActivityIndicator()) + : _errorMessage.isNotEmpty + ? _buildError(isDark) + : _buildContent(isDark), + ), + ); + } + + Widget _buildError(bool isDark) { + return Center( + child: Padding( + padding: const EdgeInsets.all(DesignTokens.space6), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('😕', style: TextStyle(fontSize: 56)), + const SizedBox(height: DesignTokens.space4), + Text( + _errorMessage, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space4), + CupertinoButton.filled( + onPressed: _loadData, + child: const Text('重试'), + ), + ], + ), + ), + ); + } + + Widget _buildContent(bool isDark) { + return ListView( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + children: [ + Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: (isDark ? DarkDesignTokens.primary : DesignTokens.primary) + .withValues(alpha: 0.15), + ), + ), + child: Row( + children: [ + const Text('🕐', style: TextStyle(fontSize: 32)), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '用餐时段推荐', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const SizedBox(height: 2), + Text( + '根据不同用餐时段浏览适合的菜谱', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: DesignTokens.space4), + ..._groups.map((group) => _buildGroup(group, isDark)), + const SizedBox(height: DesignTokens.space6), + ], + ); + } + + Widget _buildGroup(EatingTimesGroup group, bool isDark) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only( + left: DesignTokens.space1, + bottom: DesignTokens.space2, + ), + child: Text( + group.title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + Container( + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.1), + ), + ), + child: Column( + children: group.items.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + final isLast = index == group.items.length - 1; + return _buildTimeItem(item, isDark, isLast); + }).toList(), + ), + ), + const SizedBox(height: DesignTokens.space4), + ], + ); + } + + Widget _buildTimeItem(EatingTimeItem item, bool isDark, bool isLast) { + final timeIcon = _getTimeIcon(item.name); + + return GestureDetector( + onTap: () => _browseByTime(item), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + decoration: BoxDecoration( + border: isLast + ? null + : Border( + bottom: BorderSide( + color: + (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.08), + ), + ), + ), + child: Row( + children: [ + Text(timeIcon, style: const TextStyle(fontSize: 24)), + 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, + ), + ), + Text( + '${item.count} 道菜谱', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: + (isDark ? DarkDesignTokens.primary : DesignTokens.primary) + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '浏览', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.primary + : DesignTokens.primary, + ), + ), + ), + const SizedBox(width: 4), + Icon( + CupertinoIcons.chevron_forward, + size: 16, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ], + ), + ), + ); + } + + String _getTimeIcon(String name) { + if (name.contains('早')) return '🌅'; + if (name.contains('中') || name.contains('午')) return '🍱'; + if (name.contains('晚')) return '🌙'; + if (name.contains('零食') || name.contains('小吃')) return '🍿'; + if (name.contains('每日') || name.contains('频')) return '📅'; + if (name.contains('佐') || name.contains('餐')) return '🥘'; + return '🍽️'; + } + + Future _browseByTime(EatingTimeItem item) async { + showCupertinoModalPopup( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CupertinoActivityIndicator()), + ); + + try { + final searchKeyword = item.name + .replaceAll(RegExp(r'[时段均可作菜佐食。]'), '') + .trim(); + final result = await _recipeRepo.fetchList( + search: searchKeyword.isNotEmpty ? searchKeyword : item.name, + page: 1, + limit: 20, + ).timeout(const Duration(seconds: 10)); + + Navigator.of(context).pop(); + + if (result.items.isNotEmpty) { + Get.to( + EatingTimeRecipesPage( + title: '${_getTimeIcon(item.name)} ${item.name}', + recipes: result.items, + ), + ); + } else { + _showToast('暂无"${item.name}"相关菜谱'); + } + } catch (e) { + Navigator.of(context).pop(); + debugPrint('Browse by time error: $e'); + _showToast('加载失败,请重试'); + } + } + + void _showToast(String msg) { + Get.snackbar( + '提示', + msg, + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 2), + ); + } +} + +class EatingTimeRecipesPage extends StatelessWidget { + final String title; + final List recipes; + + const EatingTimeRecipesPage({ + super.key, + required this.title, + required this.recipes, + }); + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text( + title, + style: TextStyle( + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + backgroundColor: isDark + ? DarkDesignTokens.background.withValues(alpha: 0.85) + : DesignTokens.background.withValues(alpha: 0.85), + border: null, + ), + child: SafeArea( + child: ListView.builder( + padding: const EdgeInsets.all(DesignTokens.space4), + itemCount: recipes.length, + itemBuilder: (context, index) { + final recipe = recipes[index]; + return _buildRecipeCard(recipe, isDark); + }, + ), + ), + ); + } + + Widget _buildRecipeCard(RecipeModel recipe, bool isDark) { + return GestureDetector( + onTap: () => Get.toNamed('/recipe-detail', arguments: '${recipe.id}'), + child: Container( + margin: const EdgeInsets.only(bottom: DesignTokens.space2), + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.1), + ), + ), + child: Row( + children: [ + if (recipe.cover != null && recipe.cover!.isNotEmpty) + ClipRRect( + borderRadius: DesignTokens.borderRadiusSm, + child: Image.network( + recipe.cover!, + width: 64, + height: 64, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + width: 64, + height: 64, + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.1) + : DesignTokens.text3.withValues(alpha: 0.05), + child: const Icon(CupertinoIcons.photo, size: 24), + ), + ), + ) + else + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.1) + : DesignTokens.text3.withValues(alpha: 0.05), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: const Icon(CupertinoIcons.photo, size: 24), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recipe.title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (recipe.intro != null && recipe.intro!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + recipe.intro!, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + if (recipe.categoryName != null && + recipe.categoryName!.isNotEmpty) ...[ + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.primary : DesignTokens.primary) + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + recipe.categoryName!, + style: TextStyle( + fontSize: 10, + color: isDark ? DarkDesignTokens.primary : DesignTokens.primary, + ), + ), + ), + ], + ], + ), + ), + Icon( + CupertinoIcons.chevron_forward, + size: 16, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/pages/tools/ingredient_detail_page.dart b/lib/src/pages/tools/ingredient_detail_page.dart index 3400ed3..47e4719 100644 --- a/lib/src/pages/tools/ingredient_detail_page.dart +++ b/lib/src/pages/tools/ingredient_detail_page.dart @@ -2,13 +2,15 @@ * 文件: ingredient_detail_page.dart * 名称: 食材详情查询页面 * 作用: 查询食材营养信息与选购指南 - * 更新: 2026-04-10 初始创建 + * 创建: 2026-04-10 + * 更新: 2026-04-11 接入营养数据库,展示真实营养数据+选购指南+营养素进度条 */ import 'package:flutter/cupertino.dart'; import 'package:get/get.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; import 'package:mom_kitchen/src/repositories/recipe_repository.dart'; +import 'package:mom_kitchen/src/services/data/ingredient_nutrition_db.dart'; class IngredientDetailPage extends StatefulWidget { const IngredientDetailPage({super.key}); @@ -62,20 +64,72 @@ class _IngredientDetailPageState extends State { if (name.contains('鸡') || name.contains('猪') || name.contains('牛') || - name.contains('鱼')) { + name.contains('羊') || + name.contains('鱼') || + name.contains('虾') || + name.contains('蟹') || + name.contains('肉')) { return '肉类'; } else if (name.contains('菜') || name.contains('椒') || name.contains('葱') || - name.contains('蒜')) { + name.contains('蒜') || + name.contains('姜') || + name.contains('茄') || + name.contains('瓜') || + name.contains('豆角') || + name.contains('芹') || + name.contains('韭') || + name.contains('藕') || + name.contains('笋') || + name.contains('菇') || + name.contains('蘑') || + name.contains('耳') || + name.contains('带') || + name.contains('萝') || + name.contains('薯') || + name.contains('米') && name.contains('玉')) { return '蔬菜'; } else if (name.contains('油') || name.contains('盐') || name.contains('酱') || - name.contains('醋')) { + name.contains('醋') || + name.contains('糖') || + name.contains('蜜') || + name.contains('味') || + name.contains('料') || + name.contains('酒')) { return '调料'; - } else if (name.contains('面') || name.contains('米') || name.contains('粉')) { + } else if (name.contains('面') || + name.contains('米') || + name.contains('粉') || + name.contains('饼') || + name.contains('馒头') || + name.contains('包')) { return '主食'; + } else if (name.contains('蛋') || + name.contains('奶') || + name.contains('酪') || + name.contains('腐') || + name.contains('豆')) { + return '蛋奶豆'; + } else if (name.contains('果') || + name.contains('桃') || + name.contains('蕉') || + name.contains('莓') || + name.contains('橙') || + name.contains('橘') || + name.contains('柠') || + name.contains('瓜') && !name.contains('冬') && !name.contains('南') && !name.contains('黄')) { + return '水果'; + } else if (name.contains('坚果') || + name.contains('花生') || + name.contains('芝麻') || + name.contains('核桃') || + name.contains('腰果') || + name.contains('栗') || + name.contains('杏')) { + return '坚果'; } return '其他'; } @@ -99,9 +153,8 @@ class _IngredientDetailPageState extends State { 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( '🥕 食材详情', @@ -136,7 +189,7 @@ class _IngredientDetailPageState extends State { height: 40, decoration: BoxDecoration( color: isDark - ? DarkDesignTokens.card + ? DarkDesignTokens.segmentedBg : DesignTokens.text3.withValues(alpha: 0.08), borderRadius: DesignTokens.borderRadiusMd, ), @@ -228,6 +281,7 @@ class _IngredientDetailPageState extends State { final name = ingredient['name'] as String? ?? ''; final count = ingredient['count'] as int? ?? 0; final category = ingredient['category'] as String? ?? ''; + final nutrition = IngredientNutritionDb.lookup(name); return GestureDetector( onTap: () { @@ -248,9 +302,8 @@ class _IngredientDetailPageState extends State { width: 48, height: 48, decoration: BoxDecoration( - color: - (isDark ? DarkDesignTokens.primary : DesignTokens.primary) - .withValues(alpha: 0.1), + color: (isDark ? DarkDesignTokens.primary : DesignTokens.primary) + .withValues(alpha: 0.1), borderRadius: DesignTokens.borderRadiusMd, ), child: Center( @@ -296,6 +349,18 @@ class _IngredientDetailPageState extends State { ), ), const SizedBox(width: 8), + if (nutrition != null) ...[ + Text( + '${nutrition.calories.toInt()} kcal/${nutrition.unit}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + const SizedBox(width: 8), + ], Text( '$count 道菜谱', style: TextStyle( @@ -326,20 +391,33 @@ class _IngredientDetailPageState extends State { final category = _selectedIngredient?['category'] as String? ?? ''; final count = _selectedIngredient?['count'] as int? ?? 0; + final nutrition = + IngredientNutritionDb.lookup(name) ?? + IngredientNutritionDb.getFallback(name, category); + return ListView( padding: const EdgeInsets.all(DesignTokens.space4), children: [ - _buildDetailHeader(name, category, isDark), + _buildDetailHeader(name, category, nutrition, isDark), const SizedBox(height: DesignTokens.space4), - _buildNutritionCard(isDark), + _buildCalorieOverview(nutrition, isDark), const SizedBox(height: DesignTokens.space3), - _buildTipsCard(isDark), + _buildNutritionBars(nutrition, isDark), const SizedBox(height: DesignTokens.space3), + _buildKeyNutrientsCard(nutrition, isDark), + const SizedBox(height: DesignTokens.space3), + _buildSeasonCard(nutrition, isDark), + const SizedBox(height: DesignTokens.space3), + _buildPurchaseTipCard(nutrition, isDark), + const SizedBox(height: DesignTokens.space3), + _buildStorageTipCard(nutrition, isDark), + const SizedBox(height: DesignTokens.space4), CupertinoButton.filled( + borderRadius: DesignTokens.borderRadiusMd, onPressed: () { Get.toNamed('/search', arguments: name); }, - child: Text('查看 $count 道相关菜谱'), + child: Text('🔍 查看 $count 道相关菜谱'), ), const SizedBox(height: DesignTokens.space3), CupertinoButton( @@ -350,11 +428,17 @@ class _IngredientDetailPageState extends State { }, child: const Text('返回列表'), ), + const SizedBox(height: DesignTokens.space4), ], ); } - Widget _buildDetailHeader(String name, String category, bool isDark) { + Widget _buildDetailHeader( + String name, + String category, + IngredientNutritionData nutrition, + bool isDark, + ) { return Container( padding: const EdgeInsets.all(DesignTokens.space4), decoration: BoxDecoration( @@ -372,7 +456,7 @@ class _IngredientDetailPageState extends State { Text( name, style: TextStyle( - fontSize: DesignTokens.fontXl, + fontSize: DesignTokens.fontXxl, fontWeight: FontWeight.w700, color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, ), @@ -392,14 +476,45 @@ class _IngredientDetailPageState extends State { ), ), ), + const SizedBox(height: DesignTokens.space3), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${nutrition.calories.toInt()}', + style: TextStyle( + fontSize: 36, + fontWeight: FontWeight.w700, + color: + isDark ? DarkDesignTokens.primary : DesignTokens.primary, + ), + ), + const SizedBox(width: 4), + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + 'kcal/${nutrition.unit}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ), + ], + ), ], ), ); } - Widget _buildNutritionCard(bool isDark) { + Widget _buildCalorieOverview( + IngredientNutritionData nutrition, + bool isDark, + ) { return Container( - padding: const EdgeInsets.all(DesignTokens.space3), + padding: const EdgeInsets.all(DesignTokens.space4), decoration: BoxDecoration( color: isDark ? DarkDesignTokens.card : DesignTokens.card, borderRadius: DesignTokens.borderRadiusLg, @@ -413,57 +528,135 @@ class _IngredientDetailPageState extends State { const Text('📊', style: TextStyle(fontSize: 18)), const SizedBox(width: DesignTokens.space2), Text( - '营养价值', + '营养概览', style: TextStyle( - fontSize: DesignTokens.fontMd, + fontSize: DesignTokens.fontLg, fontWeight: FontWeight.w600, color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, ), ), ], ), + const SizedBox(height: DesignTokens.space4), + Row( + children: [ + Expanded( + child: _buildMacroItem( + '🔥 热量', + '${nutrition.calories.toInt()}', + 'kcal', + DesignTokens.orange, + isDark, + ), + ), + Expanded( + child: _buildMacroItem( + '💪 蛋白质', + '${nutrition.protein.toInt()}', + 'g', + DesignTokens.red, + isDark, + ), + ), + Expanded( + child: _buildMacroItem( + '🧈 脂肪', + '${nutrition.fat.toInt()}', + 'g', + DesignTokens.secondary, + isDark, + ), + ), + ], + ), const SizedBox(height: DesignTokens.space3), - _buildNutritionRow('热量', '约 100-150 kcal/100g', isDark), - _buildNutritionRow('蛋白质', '丰富', isDark), - _buildNutritionRow('维生素', 'A、B、C', isDark), - _buildNutritionRow('矿物质', '钙、铁、锌', isDark), + Row( + children: [ + Expanded( + child: _buildMacroItem( + '🍞 碳水', + '${nutrition.carbs.toInt()}', + 'g', + DesignTokens.green, + isDark, + ), + ), + Expanded( + child: _buildMacroItem( + '🌾 纤维', + '${nutrition.fiber.toInt()}', + 'g', + const Color(0xFF8E8E93), + isDark, + ), + ), + const Expanded(child: SizedBox()), + ], + ), ], ), ); } - Widget _buildNutritionRow(String label, String value, bool isDark) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - SizedBox( - width: 80, - child: Text( - label, - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), + Widget _buildMacroItem( + String label, + String value, + String unit, + Color color, + bool isDark, + ) { + return Column( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: DesignTokens.borderRadiusMd, ), - Expanded( + child: Center( child: Text( value, style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w700, + color: color, ), ), ), - ], - ), + ), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + Text( + unit, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], ); } - Widget _buildTipsCard(bool isDark) { + Widget _buildNutritionBars( + IngredientNutritionData nutrition, + bool isDark, + ) { + final total = nutrition.protein + nutrition.fat + nutrition.carbs; + if (total == 0) return const SizedBox(); + + final proteinPct = total > 0 ? nutrition.protein / total : 0.0; + final fatPct = total > 0 ? nutrition.fat / total : 0.0; + final carbsPct = total > 0 ? nutrition.carbs / total : 0.0; + return Container( - padding: const EdgeInsets.all(DesignTokens.space3), + padding: const EdgeInsets.all(DesignTokens.space4), decoration: BoxDecoration( color: isDark ? DarkDesignTokens.card : DesignTokens.card, borderRadius: DesignTokens.borderRadiusLg, @@ -474,12 +667,12 @@ class _IngredientDetailPageState extends State { children: [ Row( children: [ - const Text('💡', style: TextStyle(fontSize: 18)), + const Text('🥧', style: TextStyle(fontSize: 18)), const SizedBox(width: DesignTokens.space2), Text( - '选购技巧', + '营养素占比', style: TextStyle( - fontSize: DesignTokens.fontMd, + fontSize: DesignTokens.fontLg, fontWeight: FontWeight.w600, color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, ), @@ -487,12 +680,352 @@ class _IngredientDetailPageState extends State { ], ), const SizedBox(height: DesignTokens.space3), - Text( - '• 选择新鲜、无异味的产品\n• 注意保质期和储存条件\n• 优先选择有机或绿色认证产品', - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - height: 1.6, + ClipRRect( + borderRadius: DesignTokens.borderRadiusFull, + child: SizedBox( + height: 12, + child: Row( + children: [ + if (proteinPct > 0) + Expanded( + flex: (proteinPct * 100).round().clamp(1, 100), + child: Container(color: DesignTokens.red), + ), + if (fatPct > 0) + Expanded( + flex: (fatPct * 100).round().clamp(1, 100), + child: Container(color: DesignTokens.secondary), + ), + if (carbsPct > 0) + Expanded( + flex: (carbsPct * 100).round().clamp(1, 100), + child: Container(color: DesignTokens.green), + ), + ], + ), + ), + ), + const SizedBox(height: DesignTokens.space3), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildLegendItem( + '💪 蛋白质', + '${(proteinPct * 100).toStringAsFixed(0)}%', + DesignTokens.red, + isDark, + ), + _buildLegendItem( + '🧈 脂肪', + '${(fatPct * 100).toStringAsFixed(0)}%', + DesignTokens.secondary, + isDark, + ), + _buildLegendItem( + '🍞 碳水', + '${(carbsPct * 100).toStringAsFixed(0)}%', + DesignTokens.green, + isDark, + ), + ], + ), + ], + ), + ); + } + + Widget _buildLegendItem(String label, String value, Color color, bool isDark) { + return Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ); + } + + Widget _buildKeyNutrientsCard( + IngredientNutritionData nutrition, + bool isDark, + ) { + if (nutrition.keyNutrients.isEmpty) return const SizedBox(); + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('✨', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Text( + '关键营养素', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Wrap( + spacing: DesignTokens.space2, + runSpacing: DesignTokens.space2, + children: nutrition.keyNutrients.map((nutrient) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: (isDark + ? DarkDesignTokens.primary + : DesignTokens.primary) + .withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: Text( + nutrient, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.primary + : DesignTokens.primary, + ), + ), + ); + }).toList(), + ), + ], + ), + ); + } + + Widget _buildSeasonCard(IngredientNutritionData nutrition, bool isDark) { + if (nutrition.season.isEmpty) return const SizedBox(); + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('📅', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Text( + '最佳时令', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Row( + children: [ + _buildSeasonChip('春', nutrition.season.contains('春'), isDark), + _buildSeasonChip('夏', nutrition.season.contains('夏'), isDark), + _buildSeasonChip('秋', nutrition.season.contains('秋'), isDark), + _buildSeasonChip('冬', nutrition.season.contains('冬'), isDark), + if (nutrition.season.contains('四季')) + _buildSeasonChip('四季', true, isDark), + ], + ), + ], + ), + ); + } + + Widget _buildSeasonChip(String label, bool active, bool isDark) { + return Container( + margin: const EdgeInsets.only(right: DesignTokens.space2), + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: active + ? DesignTokens.green.withValues(alpha: 0.15) + : isDark + ? DarkDesignTokens.segmentedBg + : DesignTokens.text3.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: active ? FontWeight.w600 : FontWeight.normal, + color: active + ? DesignTokens.green + : isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ); + } + + Widget _buildPurchaseTipCard( + IngredientNutritionData nutrition, + bool isDark, + ) { + if (nutrition.purchaseTip.isEmpty) return const SizedBox(); + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('🛒', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Text( + '选购技巧', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Container( + width: double.infinity, + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: DesignTokens.green.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('✅', style: TextStyle(fontSize: 14)), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Text( + nutrition.purchaseTip, + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + height: 1.5, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildStorageTipCard( + IngredientNutritionData nutrition, + bool isDark, + ) { + if (nutrition.storageTip.isEmpty) return const SizedBox(); + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('📦', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Text( + '储存方法', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Container( + width: double.infinity, + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.primary : DesignTokens.primary) + .withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('💡', style: TextStyle(fontSize: 14)), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Text( + nutrition.storageTip, + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + height: 1.5, + ), + ), + ), + ], ), ), ], @@ -510,6 +1043,12 @@ class _IngredientDetailPageState extends State { return '🧂'; case '主食': return '🍚'; + case '蛋奶豆': + return '🥚'; + case '水果': + return '🍎'; + case '坚果': + return '🥜'; default: return '🥕'; } diff --git a/lib/src/pages/tools/meal_planner_page.dart b/lib/src/pages/tools/meal_planner_page.dart index de99bfb..b056f83 100644 --- a/lib/src/pages/tools/meal_planner_page.dart +++ b/lib/src/pages/tools/meal_planner_page.dart @@ -1,13 +1,17 @@ /* * 文件: meal_planner_page.dart * 名称: 每周菜单规划页面 - * 作用: 规划一周饮食菜单,支持早中晚三餐 - * 更新: 2026-04-10 初始创建 + * 作用: 规划一周饮食菜单,支持早中晚三餐,数据持久化 + * 创建: 2026-04-10 + * 更新: 2026-04-11 添加Hive持久化+从收藏添加功能 */ +import 'dart:convert'; import 'package:flutter/cupertino.dart'; import 'package:get/get.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/controllers/favorites_controller.dart'; +import 'package:mom_kitchen/src/services/data/storage_service.dart'; class MealPlannerPage extends StatefulWidget { const MealPlannerPage({super.key}); @@ -23,10 +27,14 @@ class _MealPlannerPageState extends State { final Map> _mealPlan = {}; + static const String _storageKey = 'meal_plan_data'; + static const String _storageWeekKey = 'meal_plan_week_start'; + @override void initState() { super.initState(); _initMealPlan(); + _loadFromStorage(); } void _initMealPlan() { @@ -41,12 +49,80 @@ class _MealPlannerPageState extends State { return now.subtract(Duration(days: weekday - 1)); } + String _getWeekStartKey() { + final start = _getWeekStartDate(); + return '${start.year}-${start.month}-${start.day}'; + } + String _getDateString(int dayIndex) { final startDate = _getWeekStartDate(); final date = startDate.add(Duration(days: dayIndex)); return '${date.month}/${date.day}'; } + Future _loadFromStorage() async { + try { + final storage = StorageService(); + if (!storage.isInitialized) return; + + final savedWeek = storage.getString(_storageWeekKey); + final currentWeek = _getWeekStartKey(); + + if (savedWeek != currentWeek) { + await storage.remove(_storageKey); + await storage.setString(_storageWeekKey, currentWeek); + return; + } + + final savedData = storage.getString(_storageKey); + if (savedData == null || savedData.isEmpty) return; + + final decoded = jsonDecode(savedData) as Map; + setState(() { + for (final day in _weekDays) { + if (decoded.containsKey(day)) { + final dayData = decoded[day] as Map; + for (final meal in _mealTypes) { + if (dayData.containsKey(meal)) { + _mealPlan[day]?[meal] = dayData[meal] as String? ?? ''; + } + } + } + } + }); + } catch (e) { + debugPrint('加载菜单规划失败: $e'); + } + } + + Future _saveToStorage() async { + try { + final storage = StorageService(); + if (!storage.isInitialized) return; + + final encoded = jsonEncode(_mealPlan); + await storage.setString(_storageKey, encoded); + await storage.setString(_storageWeekKey, _getWeekStartKey()); + } catch (e) { + debugPrint('保存菜单规划失败: $e'); + } + } + + void _updateMeal(String dayName, String mealType, String value) { + setState(() { + _mealPlan[dayName]?[mealType] = value; + }); + _saveToStorage(); + } + + void _removeMeal(String mealType) { + setState(() { + final dayName = _weekDays[_selectedDayIndex]; + _mealPlan[dayName]?[mealType] = ''; + }); + _saveToStorage(); + } + @override Widget build(BuildContext context) { final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; @@ -407,16 +483,47 @@ class _MealPlannerPageState extends State { void _addFromSearch(String mealType) { Get.toNamed('/search')?.then((result) { if (result != null && result is String) { - setState(() { - final dayName = _weekDays[_selectedDayIndex]; - _mealPlan[dayName]?[mealType] = result; - }); + _updateMeal(_weekDays[_selectedDayIndex], mealType, result); } }); } void _addFromFavorites([String? mealType]) { - Get.snackbar('提示', '从收藏选择功能开发中', snackPosition: SnackPosition.BOTTOM); + final mt = mealType ?? '午餐'; + try { + final favController = Get.find(); + final favorites = favController.allFavorites; + + if (favorites.isEmpty) { + Get.snackbar('提示', '收藏列表为空,请先收藏菜谱 ❤️', + snackPosition: SnackPosition.BOTTOM); + return; + } + + showCupertinoModalPopup( + context: context, + builder: (ctx) => CupertinoActionSheet( + title: Text('选择$mt菜谱'), + message: const Text('从收藏中选择一道菜谱'), + actions: favorites.take(8).map((item) { + return CupertinoActionSheetAction( + onPressed: () { + Get.back(); + _updateMeal(_weekDays[_selectedDayIndex], mt, item.title); + }, + child: Text(item.title, overflow: TextOverflow.ellipsis), + ); + }).toList(), + cancelButton: CupertinoActionSheetAction( + onPressed: () => Get.back(), + child: const Text('取消'), + ), + ), + ); + } catch (e) { + Get.snackbar('提示', '无法获取收藏列表', + snackPosition: SnackPosition.BOTTOM); + } } void _addCustomMeal(String mealType) { @@ -442,10 +549,7 @@ class _MealPlannerPageState extends State { CupertinoDialogAction( onPressed: () { if (controller.text.isNotEmpty) { - setState(() { - final dayName = _weekDays[_selectedDayIndex]; - _mealPlan[dayName]?[mealType] = controller.text; - }); + _updateMeal(_weekDays[_selectedDayIndex], mealType, controller.text); } Get.back(); }, @@ -455,11 +559,4 @@ class _MealPlannerPageState extends State { ), ); } - - void _removeMeal(String mealType) { - setState(() { - final dayName = _weekDays[_selectedDayIndex]; - _mealPlan[dayName]?[mealType] = ''; - }); - } } diff --git a/lib/src/pages/tools/serving_scaler_page.dart b/lib/src/pages/tools/serving_scaler_page.dart index 8c2fd03..c1f99bb 100644 --- a/lib/src/pages/tools/serving_scaler_page.dart +++ b/lib/src/pages/tools/serving_scaler_page.dart @@ -1,70 +1,485 @@ -// 份量缩放工具页面 -// 创建时间: 2026-04-09 -// 更新时间: 2026-04-09 -// 名称: serving_scaler_page.dart -// 作用: 实现菜谱份量缩放功能 -// 上次更新内容: 初始创建 +/* + * 文件: serving_scaler_page.dart + * 名称: 份量缩放+单位换算工具页面 + * 作用: 实现菜谱份量缩放功能和食材单位换算 + * 创建: 2026-04-09 + * 更新: 2026-04-11 支持从菜谱详情页导入食材+添加单位换算Tab + */ import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; + +enum _ScalerTab { scale, convert } class ServingScalerPage extends StatefulWidget { - const ServingScalerPage({super.key}); + final List>? ingredients; + final int? defaultServings; + + const ServingScalerPage({super.key, this.ingredients, this.defaultServings}); @override State createState() => _ServingScalerPageState(); } class _ServingScalerPageState extends State { + _ScalerTab _currentTab = _ScalerTab.scale; int _originalServings = 4; int _targetServings = 4; - final List _ingredients = [ - Ingredient('面粉', '200', 'g'), - Ingredient('鸡蛋', '2', '个'), - Ingredient('牛奶', '250', 'ml'), - Ingredient('糖', '50', 'g'), - Ingredient('黄油', '100', 'g'), - ]; + final List<_ScalerIngredient> _ingredients = []; + final _nameController = TextEditingController(); + final _amountController = TextEditingController(); + final _unitController = TextEditingController(); + + final _convertValueController = TextEditingController(); + String _convertFromUnit = 'g'; + String _convertToUnit = 'kg'; + _UnitCategory _convertCategory = _UnitCategory.weight; + + static const Map<_UnitCategory, List> _unitMap = { + _UnitCategory.weight: ['g', 'kg', 'lb', 'oz', '斤', '两'], + _UnitCategory.volume: ['ml', 'L', '杯', '汤匙', '茶匙', 'fl oz'], + _UnitCategory.count: ['个', '根', '片', '瓣', '条', '块', '把', '勺', '滴'], + }; + + static const Map _toBaseUnit = { + 'g': 1, + 'kg': 1000, + 'lb': 453.592, + 'oz': 28.3495, + '斤': 500, + '两': 50, + 'ml': 1, + 'L': 1000, + '杯': 240, + '汤匙': 15, + '茶匙': 5, + 'fl oz': 29.5735, + }; + + @override + void initState() { + super.initState(); + if (widget.defaultServings != null && widget.defaultServings! > 0) { + _originalServings = widget.defaultServings!; + _targetServings = widget.defaultServings!; + } + if (widget.ingredients != null && widget.ingredients!.isNotEmpty) { + for (final ing in widget.ingredients!) { + _ingredients.add( + _ScalerIngredient( + name: ing['name'] ?? '', + amount: ing['amount'] ?? '', + unit: ing['unit'] ?? '', + ), + ); + } + } else { + _ingredients.addAll([ + _ScalerIngredient(name: '面粉', amount: '200', unit: 'g'), + _ScalerIngredient(name: '鸡蛋', amount: '2', unit: '个'), + _ScalerIngredient(name: '牛奶', amount: '250', unit: 'ml'), + _ScalerIngredient(name: '糖', amount: '50', unit: 'g'), + _ScalerIngredient(name: '黄油', amount: '100', unit: 'g'), + ]); + } + } + + @override + void dispose() { + _nameController.dispose(); + _amountController.dispose(); + _unitController.dispose(); + _convertValueController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + return CupertinoPageScaffold( + backgroundColor: isDark + ? DarkDesignTokens.background + : DesignTokens.background, navigationBar: CupertinoNavigationBar( - middle: const Text('份量缩放工具'), - backgroundColor: CupertinoColors.systemBackground, - ), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 原始份量 - _buildServingControl('原始份量', _originalServings, (value) { - setState(() { - _originalServings = value; - }); - }), - const SizedBox(height: 24), - - // 目标份量 - _buildServingControl('目标份量', _targetServings, (value) { - setState(() { - _targetServings = value; - }); - }), - const SizedBox(height: 32), - - // 缩放比例 - _buildScaleRatio(), - const SizedBox(height: 32), - - // 食材列表 - _buildIngredientsList(), - ], + middle: Text( + '⚖️ 份量缩放', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, ), ), + trailing: _currentTab == _ScalerTab.scale + ? CupertinoButton( + padding: EdgeInsets.zero, + onPressed: _showAddIngredientDialog, + child: Icon( + CupertinoIcons.add, + color: isDark + ? DarkDesignTokens.primary + : DesignTokens.primary, + size: 28, + ), + ) + : null, + backgroundColor: isDark + ? DarkDesignTokens.background.withValues(alpha: 0.9) + : DesignTokens.background.withValues(alpha: 0.9), + border: null, ), + child: SafeArea( + top: false, + child: Column( + children: [ + const SizedBox(height: DesignTokens.space3), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + child: SizedBox( + width: 280, + child: CupertinoSegmentedControl<_ScalerTab>( + groupValue: _currentTab, + onValueChanged: (value) { + setState(() { + _currentTab = value; + }); + }, + children: const { + _ScalerTab.scale: Padding( + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + child: Text('份量缩放'), + ), + _ScalerTab.convert: Padding( + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + child: Text('单位换算'), + ), + }, + ), + ), + ), + const SizedBox(height: DesignTokens.space3), + Expanded( + child: _currentTab == _ScalerTab.scale + ? _buildScaleTab(isDark) + : _buildConvertTab(isDark), + ), + ], + ), + ), + ); + } + + Widget _buildScaleTab(bool isDark) { + return Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildServingControl( + '原始份量', + _originalServings, + (value) => setState(() => _originalServings = value), + isDark, + ), + const SizedBox(height: DesignTokens.space4), + _buildServingControl( + '目标份量', + _targetServings, + (value) => setState(() => _targetServings = value), + isDark, + ), + const SizedBox(height: DesignTokens.space4), + _buildScaleRatio(isDark), + const SizedBox(height: DesignTokens.space4), + Expanded(child: _buildIngredientsList(isDark)), + ], + ), + ); + } + + Widget _buildConvertTab(bool isDark) { + final result = _calculateConversion(); + + return SingleChildScrollView( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCategorySelector(isDark), + const SizedBox(height: DesignTokens.space4), + Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: CupertinoTextField( + controller: _convertValueController, + placeholder: '输入数值', + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + onChanged: (_) => setState(() {}), + padding: const EdgeInsets.all(DesignTokens.space3), + style: TextStyle( + fontSize: DesignTokens.fontXl, + fontWeight: FontWeight.bold, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + ), + const SizedBox(width: DesignTokens.space2), + _buildUnitPicker( + _convertFromUnit, + (v) => setState(() => _convertFromUnit = v), + isDark, + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Icon( + CupertinoIcons.arrow_down, + color: isDark + ? DarkDesignTokens.primary + : DesignTokens.primary, + size: 28, + ), + const SizedBox(height: DesignTokens.space3), + Container( + width: double.infinity, + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: + (isDark + ? DarkDesignTokens.primary + : DesignTokens.primary) + .withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + result, + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.bold, + color: isDark + ? DarkDesignTokens.primary + : DesignTokens.primary, + ), + ), + const SizedBox(height: 4), + Text( + '$_convertFromUnit → $_convertToUnit', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: DesignTokens.space4), + _buildQuickConvertGrid(isDark), + ], + ), + ); + } + + Widget _buildCategorySelector(bool isDark) { + return Row( + children: _UnitCategory.values.map((cat) { + final isSelected = _convertCategory == cat; + return Expanded( + child: GestureDetector( + onTap: () { + setState(() { + _convertCategory = cat; + final units = _unitMap[cat]!; + _convertFromUnit = units.first; + _convertToUnit = units.length > 1 ? units[1] : units.first; + }); + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 2), + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: isSelected + ? (isDark ? DarkDesignTokens.primary : DesignTokens.primary) + : (isDark ? DarkDesignTokens.card : DesignTokens.card), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Text( + cat.label, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: isSelected + ? CupertinoColors.white + : (isDark ? DarkDesignTokens.text2 : DesignTokens.text2), + ), + ), + ), + ), + ); + }).toList(), + ); + } + + Widget _buildUnitPicker( + String current, + Function(String) onChanged, + bool isDark, + ) { + final units = _unitMap[_convertCategory] ?? []; + return GestureDetector( + onTap: () { + showCupertinoModalPopup( + context: context, + builder: (ctx) => CupertinoActionSheet( + actions: units.map((unit) { + return CupertinoActionSheetAction( + onPressed: () { + onChanged(unit); + Navigator.pop(ctx); + }, + child: Text( + unit, + style: TextStyle( + color: unit == current + ? DesignTokens.primary + : (isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1), + ), + ), + ); + }).toList(), + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + ), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + current, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(width: 4), + Icon( + CupertinoIcons.chevron_down, + size: 14, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ], + ), + ), + ); + } + + String _calculateConversion() { + final value = double.tryParse(_convertValueController.text); + if (value == null || value == 0) return '0'; + + final fromBase = _toBaseUnit[_convertFromUnit]; + final toBase = _toBaseUnit[_convertToUnit]; + if (fromBase == null || toBase == null || toBase == 0) return '—'; + + final result = value * fromBase / toBase; + if (result == result.roundToDouble()) { + return '${result.toInt()} $_convertToUnit'; + } + return '${result.toStringAsFixed(2)} $_convertToUnit'; + } + + Widget _buildQuickConvertGrid(bool isDark) { + final quickConverts = [ + {'from': '1 杯', 'to': '240 ml', 'icon': '🥛'}, + {'from': '1 汤匙', 'to': '15 ml', 'icon': '🥄'}, + {'from': '1 茶匙', 'to': '5 ml', 'icon': '🫙'}, + {'from': '1 斤', 'to': '500 g', 'icon': '⚖️'}, + {'from': '1 lb', 'to': '453.6 g', 'icon': '🇺🇸'}, + {'from': '1 oz', 'to': '28.3 g', 'icon': '🇬🇧'}, + {'from': '1 L', 'to': '1000 ml', 'icon': '🫗'}, + {'from': '1 kg', 'to': '2.2 lb', 'icon': '🔄'}, + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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.space2, + children: quickConverts.map((item) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.15), + ), + ), + child: Text( + '${item['icon']} ${item['from']} = ${item['to']}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ); + }).toList(), + ), + ], ); } @@ -72,70 +487,58 @@ class _ServingScalerPageState extends State { String label, int value, Function(int) onValueChanged, + bool isDark, ) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: CupertinoColors.label, - ), - ), - const SizedBox(height: 8), - Row( - children: [ - CupertinoButton( - onPressed: value > 1 ? () => onValueChanged(value - 1) : null, - child: const Icon(CupertinoIcons.minus_circled), - ), - const SizedBox(width: 16), - Text( - '$value', - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: CupertinoColors.label, - ), - ), - const SizedBox(width: 16), - CupertinoButton( - onPressed: value < 20 ? () => onValueChanged(value + 1) : null, - child: const Icon(CupertinoIcons.plus_circled), - ), - ], - ), - ], - ); - } - - Widget _buildScaleRatio() { - final ratio = _targetServings / _originalServings; return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(DesignTokens.space3), decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(12), + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, ), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text( - '缩放比例', + Text( + label, style: TextStyle( - fontSize: 16, + fontSize: DesignTokens.fontMd, fontWeight: FontWeight.w500, - color: CupertinoColors.label, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, ), ), - Text( - '${ratio.toStringAsFixed(2)}x', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: CupertinoColors.systemBlue, + const Spacer(), + CupertinoButton( + padding: EdgeInsets.zero, + minimumSize: const Size(36, 36), + onPressed: value > 1 ? () => onValueChanged(value - 1) : null, + child: Icon( + CupertinoIcons.minus_circle, + color: value > 1 + ? (isDark ? DarkDesignTokens.primary : DesignTokens.primary) + : (isDark ? DarkDesignTokens.text3 : DesignTokens.text3), + ), + ), + SizedBox( + width: 48, + child: Center( + child: Text( + '$value', + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.bold, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + ), + CupertinoButton( + padding: EdgeInsets.zero, + minimumSize: const Size(36, 36), + onPressed: value < 20 ? () => onValueChanged(value + 1) : null, + child: Icon( + CupertinoIcons.plus_circle, + color: value < 20 + ? (isDark ? DarkDesignTokens.primary : DesignTokens.primary) + : (isDark ? DarkDesignTokens.text3 : DesignTokens.text3), ), ), ], @@ -143,64 +546,279 @@ class _ServingScalerPageState extends State { ); } - Widget _buildIngredientsList() { - final ratio = _targetServings / _originalServings; - return Expanded( - child: ListView.builder( - itemCount: _ingredients.length, - itemBuilder: (context, index) { - final ingredient = _ingredients[index]; - final originalAmount = double.tryParse(ingredient.amount) ?? 0; - final scaledAmount = originalAmount * ratio; - - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: CupertinoColors.systemBackground, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey[200]!, width: 1), + Widget _buildScaleRatio(bool isDark) { + final ratio = _originalServings > 0 + ? _targetServings / _originalServings + : 1.0; + return Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.primary : DesignTokens.primary) + .withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '缩放比例', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + ), + Text( + '${ratio.toStringAsFixed(2)}x', + style: TextStyle( + fontSize: DesignTokens.fontXl, + fontWeight: FontWeight.bold, + color: isDark ? DarkDesignTokens.primary : DesignTokens.primary, + ), + ), + ], + ), + ); + } + + Widget _buildIngredientsList(bool isDark) { + if (_ingredients.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🥘', style: TextStyle(fontSize: 48)), + const SizedBox(height: DesignTokens.space3), + Text( + '点击右上角 + 添加食材', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ); + } + + final ratio = _originalServings > 0 + ? _targetServings / _originalServings + : 1.0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '食材列表', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const Spacer(), + Text( + '${_ingredients.length} 项', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + Expanded( + child: ListView.separated( + itemCount: _ingredients.length, + separatorBuilder: (_, __) => + const SizedBox(height: DesignTokens.space2), + itemBuilder: (context, index) { + final ing = _ingredients[index]; + final originalAmount = double.tryParse(ing.amount) ?? 0; + final scaledAmount = originalAmount * ratio; + + return _buildIngredientItem(ing, scaledAmount, index, isDark); + }, + ), + ), + ], + ); + } + + Widget _buildIngredientItem( + _ScalerIngredient ing, + double scaledAmount, + int index, + bool isDark, + ) { + return Dismissible( + key: ValueKey('ingredient_$index'), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: DesignTokens.space4), + decoration: BoxDecoration( + color: DesignTokens.red, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: const Icon(CupertinoIcons.delete, color: CupertinoColors.white), + ), + onDismissed: (_) { + setState(() { + _ingredients.removeAt(index); + }); + }, + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.15), + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + ing.name, + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - ingredient.name, - style: const TextStyle( - fontSize: 16, - color: CupertinoColors.label, + '${ing.amount} ${ing.unit}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, ), ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '${ingredient.amount} ${ingredient.unit}', - style: TextStyle(fontSize: 14, color: Colors.grey[400]), - ), - Text( - '${scaledAmount.toStringAsFixed(1)} ${ingredient.unit}', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: CupertinoColors.systemBlue, - ), - ), - ], + Text( + '${scaledAmount.toStringAsFixed(1)} ${ing.unit}', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.bold, + color: isDark + ? DarkDesignTokens.primary + : DesignTokens.primary, + ), ), ], ), - ); - }, + ], + ), + ), + ); + } + + void _showAddIngredientDialog() { + _nameController.clear(); + _amountController.clear(); + _unitController.clear(); + + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('🥘 添加食材'), + content: Padding( + padding: const EdgeInsets.only(top: DesignTokens.space3), + child: Column( + children: [ + CupertinoTextField( + controller: _nameController, + placeholder: '食材名称', + autofocus: true, + ), + const SizedBox(height: DesignTokens.space2), + Row( + children: [ + Expanded( + flex: 2, + child: CupertinoTextField( + controller: _amountController, + placeholder: '用量', + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: CupertinoTextField( + controller: _unitController, + placeholder: '单位', + ), + ), + ], + ), + ], + ), + ), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () { + if (_nameController.text.trim().isNotEmpty) { + setState(() { + _ingredients.add( + _ScalerIngredient( + name: _nameController.text.trim(), + amount: _amountController.text.trim().isEmpty + ? '0' + : _amountController.text.trim(), + unit: _unitController.text.trim().isEmpty + ? 'g' + : _unitController.text.trim(), + ), + ); + }); + } + Navigator.pop(ctx); + }, + child: const Text('添加'), + ), + ], ), ); } } -class Ingredient { +class _ScalerIngredient { final String name; final String amount; final String unit; - Ingredient(this.name, this.amount, this.unit); + _ScalerIngredient({ + required this.name, + required this.amount, + required this.unit, + }); +} + +enum _UnitCategory { + weight, + volume, + count; + + String get label { + switch (this) { + case _UnitCategory.weight: + return '⚖️ 重量'; + case _UnitCategory.volume: + return '🥛 容量'; + case _UnitCategory.count: + return '🔢 计数'; + } + } } diff --git a/lib/src/pages/tools/tools_center_page.dart b/lib/src/pages/tools/tools_center_page.dart index 8065037..f8f909c 100644 --- a/lib/src/pages/tools/tools_center_page.dart +++ b/lib/src/pages/tools/tools_center_page.dart @@ -4,6 +4,8 @@ * 作用: 展示所有工具,支持分类筛选和搜索 * 更新: 2026-04-10 初始创建 * 更新: 2026-04-10 修复卡死问题:使用Get.find()+添加错误处理 + * 更新: 2026-04-11 简化ToolsController获取(已全局注册,移除防御性put) + * 更新: 2026-04-11 修复:添加try-catch防止Get.find失败导致闪退 */ import 'package:flutter/cupertino.dart'; @@ -19,14 +21,50 @@ class ToolsCenterPage extends StatelessWidget { Widget build(BuildContext context) { final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; - ToolsController controller; - try { - controller = Get.find(); - } catch (e) { - debugPrint('ToolsController not found, creating new instance: $e'); - controller = Get.put(ToolsController(), permanent: true); + if (!Get.isRegistered()) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text( + '🛠️ 工具中心', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + backgroundColor: isDark + ? DarkDesignTokens.background.withValues(alpha: 0.9) + : DesignTokens.background.withValues(alpha: 0.9), + border: null, + ), + child: SafeArea( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🛠️', style: TextStyle(fontSize: 48)), + const SizedBox(height: DesignTokens.space3), + Text( + '工具控制器未就绪', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space3), + CupertinoButton.filled( + onPressed: () => Get.back(), + child: const Text('返回'), + ), + ], + ), + ), + ), + ); } + final controller = Get.find(); + return CupertinoPageScaffold( backgroundColor: isDark ? DarkDesignTokens.background diff --git a/lib/src/repositories/hot_repository.dart b/lib/src/repositories/hot_repository.dart index 40bf017..4a19a56 100644 --- a/lib/src/repositories/hot_repository.dart +++ b/lib/src/repositories/hot_repository.dart @@ -152,14 +152,27 @@ class HotRepository { final hotData = await _fetchHotData(period, limit); String sourceKey = 'recipe_$sortBy'; - if (!hotData.containsKey(sourceKey)) { - sourceKey = hotData.keys.firstWhere( - (key) => key.startsWith('recipe_'), - orElse: () => '', - ); + if (!hotData.containsKey(sourceKey) || (hotData[sourceKey]?.isEmpty ?? true)) { + final fallbacks = [ + 'recipe_view', + 'recipe_like', + 'ingredient_view', + ]; + for (final fb in fallbacks) { + if (hotData.containsKey(fb) && hotData[fb]!.isNotEmpty) { + sourceKey = fb; + break; + } + } } - if (sourceKey.isEmpty || !hotData.containsKey(sourceKey)) return []; + if (!hotData.containsKey(sourceKey) || hotData[sourceKey]!.isEmpty) { + if (period != 'total') { + debugPrint('HotRepository: no data for period=$period, falling back to total'); + return fetchMergedHotList(period: 'total', sortBy: sortBy, limit: limit); + } + return []; + } return hotData[sourceKey]!.take(limit).toList(); } catch (e, stackTrace) { debugPrint('HotRepository.fetchMergedHotList error: $e'); diff --git a/lib/src/services/allergen_checker.dart b/lib/src/services/allergen_checker.dart index ea99f8b..8bee90c 100644 --- a/lib/src/services/allergen_checker.dart +++ b/lib/src/services/allergen_checker.dart @@ -1,67 +1,160 @@ // 过敏原检测服务 // 创建时间: 2026-04-09 -// 更新时间: 2026-04-09 +// 更新时间: 2026-04-11 // 名称: allergen_checker.dart -// 作用: 检测菜谱中的过敏原 -// 上次更新内容: 初始创建 +// 作用: 检测菜谱中的过敏原,接入用户偏好设置,提供替代建议 +// 上次更新内容: 添加食材替代建议功能 + +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/controllers/user/preference_controller.dart'; class AllergenChecker { static final AllergenChecker _instance = AllergenChecker._internal(); factory AllergenChecker() => _instance; AllergenChecker._internal(); - /// 检测菜谱是否包含过敏原 - /// 返回包含的过敏原列表 - List checkAllergens(String recipeIngredients) { - // 暂时返回空列表,后续可以从用户偏好设置中获取 - final blockedAllergens = []; - final detectedAllergens = []; + static const Map> _allergenKeywords = { + 'nuts': [ + '坚果', + '花生', + '核桃', + '杏仁', + '腰果', + '榛子', + '开心果', + '夏威夷果', + '松子', + '巴旦木', + '碧根果', + ], + 'seafood': [ + '海鲜', + '虾', + '蟹', + '鱼', + '贝', + '牡蛎', + '扇贝', + '鱿鱼', + '章鱼', + '三文鱼', + '鳕鱼', + '带鱼', + '鲈鱼', + '龙虾', + ], + 'dairy': ['乳制品', '牛奶', '奶酪', '黄油', '奶油', '芝士', '酸奶', '炼乳', '奶粉', '乳清'], + 'eggs': ['蛋类', '鸡蛋', '鸭蛋', '鹅蛋', '鹌鹑蛋', '蛋液', '蛋黄', '蛋白'], + 'grains': ['谷物', '小麦', '面粉', '大麦', '燕麦', '黑麦', '麸皮', '面筋'], + 'beans': ['豆类', '大豆', '黄豆', '绿豆', '红豆', '黑豆', '豆腐', '豆浆', '豆皮', '腐竹'], + 'meat': ['肉类', '猪肉', '牛肉', '羊肉', '鸡肉', '鸭肉', '鹅肉', '排骨', '五花肉'], + 'fruits': ['水果', '芒果', '草莓', '猕猴桃', '菠萝', '桃子', '荔枝', '龙眼'], + 'vegetables': ['蔬菜', '芹菜', '香菜', '韭菜', '洋葱', '大蒜'], + 'mushrooms': ['菌类', '蘑菇', '香菇', '金针菇', '木耳', '银耳', '杏鲍菇'], + 'seasonings': ['调味品', '芝麻', '芥末', '亚硫酸盐', '味精', '鸡精'], + }; - if (blockedAllergens.isEmpty) { - return detectedAllergens; + List get _blockedAllergenTypes { + try { + if (!Get.isRegistered()) return []; + final controller = Get.find(); + return controller.blockedAllergenTypes; + } catch (_) { + return []; } + } - // 简单的文本匹配检测 - for (final allergen in blockedAllergens) { - if (recipeIngredients.toLowerCase().contains(allergen.toLowerCase())) { - detectedAllergens.add(allergen); + List checkAllergens(String recipeIngredients) { + final blockedTypes = _blockedAllergenTypes; + if (blockedTypes.isEmpty) return []; + + final detectedAllergens = []; + final lowerIngredients = recipeIngredients.toLowerCase(); + + for (final type in blockedTypes) { + final keywords = _allergenKeywords[type] ?? [type]; + for (final keyword in keywords) { + if (lowerIngredients.contains(keyword.toLowerCase())) { + if (!detectedAllergens.contains(type)) { + detectedAllergens.add(type); + } + break; + } } } return detectedAllergens; } - /// 检查单个食材是否包含过敏原 bool isAllergen(String ingredient) { - // 暂时返回 false,后续可以从用户偏好设置中获取 + final blockedTypes = _blockedAllergenTypes; + if (blockedTypes.isEmpty) return false; + + final lowerIngredient = ingredient.toLowerCase(); + for (final type in blockedTypes) { + final keywords = _allergenKeywords[type] ?? [type]; + for (final keyword in keywords) { + if (lowerIngredient.contains(keyword.toLowerCase())) { + return true; + } + } + } return false; } - /// 获取常见过敏原列表 List getCommonAllergens() { - return [ - '牛奶', - '鸡蛋', - '花生', - '坚果', - '大豆', - '小麦', - '鱼', - '贝类', - '芝麻', - '芹菜', - '芥末', - ' sulfites', - '亚硫酸盐', - ]; + return _allergenKeywords.keys.toList(); + } + + String getAllergenDisplayName(String type) { + const nameMap = { + 'nuts': '坚果 🥜', + 'seafood': '海鲜 🦐', + 'dairy': '乳制品 🥛', + 'eggs': '蛋类 🥚', + 'grains': '谷物 🌾', + 'beans': '豆类 🫘', + 'meat': '肉类 🥩', + 'fruits': '水果 🍎', + 'vegetables': '蔬菜 🥬', + 'mushrooms': '菌类 🍄', + 'seasonings': '调味品 🧂', + 'other': '其他 ⚠️', + }; + return nameMap[type] ?? type; } - /// 生成过敏原警告信息 String generateWarningMessage(List allergens) { if (allergens.isEmpty) { return '该菜谱未检测到过敏原'; } + final names = allergens.map(getAllergenDisplayName).join('、'); + return '⚠️ 警告:该菜谱包含以下过敏原:$names'; + } - return '警告:该菜谱包含以下过敏原:${allergens.join('、')}'; + static const Map> _substitutionMap = { + 'nuts': ['葵花籽', '南瓜籽', '芝麻', '燕麦片'], + 'seafood': ['鸡肉', '豆腐', '蘑菇', '鸡蛋'], + 'dairy': ['豆浆', '椰奶', '燕麦奶', '杏仁奶', '豆腐'], + 'eggs': ['嫩豆腐', '亚麻籽粉+水', '香蕉泥', '苹果泥'], + 'grains': ['米粉', '荞麦面', '红薯粉', '玉米粉'], + 'beans': ['鸡肉', '蘑菇', '面筋', '鸡蛋'], + 'meat': ['豆腐', '蘑菇', '面筋', '素肉', '鸡蛋'], + 'fruits': ['其他应季水果', '果汁', '果干'], + 'vegetables': ['其他时令蔬菜', '生菜', '白菜'], + 'mushrooms': ['豆腐', '面筋', '茄子'], + 'seasonings': ['低盐酱油', '天然香草', '柠檬汁'], + }; + + List getSubstitutions(String allergenType) { + return _substitutionMap[allergenType] ?? ['咨询营养师获取替代方案']; + } + + Map> getAllergenSubstitutions(List allergens) { + final result = >{}; + for (final type in allergens) { + result[getAllergenDisplayName(type)] = getSubstitutions(type); + } + return result; } } diff --git a/lib/src/services/api/api_service.dart b/lib/src/services/api/api_service.dart index ac54597..206a351 100644 --- a/lib/src/services/api/api_service.dart +++ b/lib/src/services/api/api_service.dart @@ -1,6 +1,8 @@ // 2026-04-09 | ApiService | HTTP客户端服务 | Web端跳过文件缓存和connectivity检查 // 2026-04-10 | API v2.0.0: 新增 _format/_stale/_refresh/_pretty 参数支持 +// 2026-04-11 | 优化: 增强日志拦截器、添加重试机制、统一离线检查、缓存数据解析修复 import 'dart:async'; +import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:dio_cache_interceptor/dio_cache_interceptor.dart'; import 'package:dio_cache_interceptor_file_store/dio_cache_interceptor_file_store.dart'; @@ -19,11 +21,18 @@ class ApiService { bool _cacheInitialized = false; Completer? _cacheInitCompleter; + static const int _maxRetries = 2; + static const Duration _retryDelay = Duration(seconds: 1); + ApiService._internal() { final options = BaseOptions( baseUrl: ApiConfig.baseUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, ); _dio = Dio(options); @@ -56,18 +65,45 @@ class ApiService { _cacheInitialized = true; _cacheInitCompleter!.complete(); } catch (e) { - _cacheInitCompleter!.completeError(e); + debugPrint('ApiService: cache init failed: $e'); + _dio.interceptors.add(_buildLogInterceptor()); + _cacheInitCompleter!.complete(); } } InterceptorsWrapper _buildLogInterceptor() { return InterceptorsWrapper( - onRequest: (options, handler) => handler.next(options), - onResponse: (response, handler) => handler.next(response), - onError: (DioException e, handler) => handler.next(e), + onRequest: (options, handler) { + if (kDebugMode) { + final params = options.queryParameters.isNotEmpty + ? '?${options.queryParameters.entries.map((e) => '${e.key}=${e.value}').join('&')}' + : ''; + debugPrint('→ ${options.method} ${options.path}$params'); + } + handler.next(options); + }, + onResponse: (response, handler) { + if (kDebugMode) { + debugPrint( + '← ${response.statusCode} ${response.requestOptions.path} (${_formatDuration(response.requestOptions.receiveTimeout)})', + ); + } + handler.next(response); + }, + onError: (DioException e, handler) { + if (kDebugMode) { + debugPrint('✗ ${e.type.name} ${e.requestOptions.path}: ${e.message}'); + } + handler.next(e); + }, ); } + String _formatDuration(Duration? d) { + if (d == null) return 'no timeout'; + return '${d.inSeconds}s'; + } + Future _isOffline() async { if (kIsWeb) return false; try { @@ -120,10 +156,12 @@ class ApiService { if (forceRefresh) params['_refresh'] = '1'; if (pretty) params['_pretty'] = '1'; - final response = await _dio.get( - path, - queryParameters: params, - options: _cacheOptions?.copyWith(policy: cachePolicy).toOptions(), + final response = await _executeWithRetry( + () => _dio.get( + path, + queryParameters: params, + options: _cacheOptions?.copyWith(policy: cachePolicy).toOptions(), + ), ); return response; } on DioException catch (e) { @@ -140,6 +178,82 @@ class ApiService { } } + Future post( + String path, { + dynamic data, + Map? queryParameters, + }) async { + return _executeWithOfflineCheck( + () => _dio.post(path, data: data, queryParameters: queryParameters), + ); + } + + Future put( + String path, { + dynamic data, + Map? queryParameters, + }) async { + return _executeWithOfflineCheck( + () => _dio.put(path, data: data, queryParameters: queryParameters), + ); + } + + Future delete( + String path, { + Map? queryParameters, + }) async { + return _executeWithOfflineCheck( + () => _dio.delete(path, queryParameters: queryParameters), + ); + } + + Future _executeWithOfflineCheck( + Future Function() request, + ) async { + try { + if (await _isOffline()) { + throw ApiException( + type: ApiExceptionType.noInternet, + message: '无网络连接,请检查网络设置', + ); + } + return await _executeWithRetry(request); + } on DioException catch (e) { + throw _convertDioException(e); + } catch (e) { + if (e is ApiException) rethrow; + throw ApiException( + type: ApiExceptionType.unknown, + message: e.toString(), + cause: e, + ); + } + } + + Future _executeWithRetry( + Future Function() request, { + int retryCount = 0, + }) async { + try { + return await request(); + } on DioException catch (e) { + final shouldRetry = + retryCount < _maxRetries && + (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout || + e.type == DioExceptionType.connectionError); + + if (shouldRetry) { + debugPrint( + 'ApiService: retry ${retryCount + 1}/$_maxRetries for ${e.requestOptions.path}', + ); + await Future.delayed(_retryDelay * (retryCount + 1)); + return _executeWithRetry(request, retryCount: retryCount + 1); + } + rethrow; + } + } + Future _tryGetCache( String path, Map? queryParameters, @@ -152,9 +266,15 @@ class ApiService { ); final entry = await cacheOpts.store?.get(key); if (entry != null) { + dynamic cachedData = entry.content; + if (cachedData is String) { + try { + cachedData = jsonDecode(cachedData); + } catch (_) {} + } return Response( requestOptions: RequestOptions(path: path), - data: entry.content, + data: cachedData, statusCode: 200, ); } @@ -162,84 +282,6 @@ class ApiService { return null; } - Future post( - String path, { - dynamic data, - Map? queryParameters, - }) async { - try { - if (await _isOffline()) { - throw ApiException( - type: ApiExceptionType.noInternet, - message: '无网络连接,请检查网络设置', - ); - } - return await _dio.post( - path, - data: data, - queryParameters: queryParameters, - ); - } on DioException catch (e) { - throw _convertDioException(e); - } catch (e) { - if (e is ApiException) rethrow; - throw ApiException( - type: ApiExceptionType.unknown, - message: e.toString(), - cause: e, - ); - } - } - - Future put( - String path, { - dynamic data, - Map? queryParameters, - }) async { - try { - if (await _isOffline()) { - throw ApiException( - type: ApiExceptionType.noInternet, - message: '无网络连接,请检查网络设置', - ); - } - return await _dio.put(path, data: data, queryParameters: queryParameters); - } on DioException catch (e) { - throw _convertDioException(e); - } catch (e) { - if (e is ApiException) rethrow; - throw ApiException( - type: ApiExceptionType.unknown, - message: e.toString(), - cause: e, - ); - } - } - - Future delete( - String path, { - Map? queryParameters, - }) async { - try { - if (await _isOffline()) { - throw ApiException( - type: ApiExceptionType.noInternet, - message: '无网络连接,请检查网络设置', - ); - } - return await _dio.delete(path, queryParameters: queryParameters); - } on DioException catch (e) { - throw _convertDioException(e); - } catch (e) { - if (e is ApiException) rethrow; - throw ApiException( - type: ApiExceptionType.unknown, - message: e.toString(), - cause: e, - ); - } - } - ApiException _convertDioException(DioException e) { switch (e.type) { case DioExceptionType.connectionTimeout: diff --git a/lib/src/services/data/ingredient_nutrition_db.dart b/lib/src/services/data/ingredient_nutrition_db.dart new file mode 100644 index 0000000..99da7fb --- /dev/null +++ b/lib/src/services/data/ingredient_nutrition_db.dart @@ -0,0 +1,913 @@ +/* + * 文件: ingredient_nutrition_db.dart + * 名称: 食材营养数据库 + * 作用: 提供常见食材的营养成分和选购指南数据 + * 创建: 2026-04-11 + * 更新: 2026-04-11 初始创建,包含60+种常见食材营养数据 + */ + +class IngredientNutritionData { + final String name; + final double calories; + final double protein; + final double fat; + final double carbs; + final double fiber; + final String unit; + final String season; + final String storageTip; + final String purchaseTip; + final List keyNutrients; + + const IngredientNutritionData({ + required this.name, + required this.calories, + required this.protein, + required this.fat, + required this.carbs, + this.fiber = 0, + this.unit = '100g', + this.season = '', + this.storageTip = '', + this.purchaseTip = '', + this.keyNutrients = const [], + }); +} + +class IngredientNutritionDb { + IngredientNutritionDb._(); + + static final Map _data = { + '鸡肉': IngredientNutritionData( + name: '鸡肉', + calories: 167, + protein: 31, + fat: 3.6, + carbs: 0, + fiber: 0, + season: '四季', + storageTip: '冷藏2-3天,冷冻可保存6个月', + purchaseTip: '肉质有弹性、颜色粉白、无异味', + keyNutrients: ['维生素B6', '烟酸', '磷', '硒'], + ), + '鸡胸肉': IngredientNutritionData( + name: '鸡胸肉', + calories: 133, + protein: 31, + fat: 1.2, + carbs: 0, + fiber: 0, + season: '四季', + storageTip: '冷藏2天,冷冻可保存6个月', + purchaseTip: '颜色淡粉、肉质紧实、表面微润', + keyNutrients: ['维生素B6', '烟酸', '蛋白质'], + ), + '鸡腿': IngredientNutritionData( + name: '鸡腿', + calories: 209, + protein: 26, + fat: 11, + carbs: 0, + season: '四季', + storageTip: '冷藏2-3天,冷冻可保存6个月', + purchaseTip: '皮色光亮、肉质有弹性', + keyNutrients: ['铁', '锌', '维生素B12'], + ), + '猪肉': IngredientNutritionData( + name: '猪肉', + calories: 242, + protein: 27, + fat: 14, + carbs: 0, + fiber: 0, + season: '四季', + storageTip: '冷藏3-4天,冷冻可保存6个月', + purchaseTip: '瘦肉鲜红、肥肉洁白、有弹性', + keyNutrients: ['维生素B1', '锌', '铁'], + ), + '五花肉': IngredientNutritionData( + name: '五花肉', + calories: 349, + protein: 21, + fat: 30, + carbs: 0, + season: '四季', + storageTip: '冷藏2-3天,冷冻可保存4个月', + purchaseTip: '肥瘦分层清晰、肉色鲜红', + keyNutrients: ['维生素B1', '锌', '蛋白质'], + ), + '排骨': IngredientNutritionData( + name: '排骨', + calories: 264, + protein: 22, + fat: 19, + carbs: 0, + season: '四季', + storageTip: '冷藏2-3天,冷冻可保存6个月', + purchaseTip: '骨头断面湿润、肉色鲜红', + keyNutrients: ['钙', '磷', '蛋白质'], + ), + '牛肉': IngredientNutritionData( + name: '牛肉', + calories: 250, + protein: 26, + fat: 15, + carbs: 0, + fiber: 0, + season: '四季', + storageTip: '冷藏3-5天,冷冻可保存6-12个月', + purchaseTip: '颜色深红、纹理清晰、有弹性', + keyNutrients: ['铁', '锌', '维生素B12', '蛋白质'], + ), + '牛腩': IngredientNutritionData( + name: '牛腩', + calories: 283, + protein: 23, + fat: 21, + carbs: 0, + season: '四季', + storageTip: '冷藏3天,冷冻可保存6个月', + purchaseTip: '肥瘦相间、肉色鲜红有光泽', + keyNutrients: ['铁', '锌', '胶原蛋白'], + ), + '羊肉': IngredientNutritionData( + name: '羊肉', + calories: 203, + protein: 25, + fat: 10, + carbs: 0, + season: '秋冬', + storageTip: '冷藏2-3天,冷冻可保存6个月', + purchaseTip: '肉色鲜红、脂肪洁白、无膻味过重', + keyNutrients: ['铁', '锌', '维生素B12'], + ), + '鱼': IngredientNutritionData( + name: '鱼', + calories: 104, + protein: 19, + fat: 2.3, + carbs: 0, + fiber: 0, + season: '四季', + storageTip: '冷藏1-2天,建议当天食用', + purchaseTip: '眼睛清亮、鳃红、鳞片完整、肉质有弹性', + keyNutrients: ['Omega-3', '维生素D', '硒'], + ), + '三文鱼': IngredientNutritionData( + name: '三文鱼', + calories: 139, + protein: 22, + fat: 5.2, + carbs: 0, + season: '秋冬', + storageTip: '冷藏1-2天,冷冻可保存3个月', + purchaseTip: '肉色橙红、纹理清晰、无腥臭', + keyNutrients: ['Omega-3', '维生素D', '蛋白质'], + ), + '虾': IngredientNutritionData( + name: '虾', + calories: 85, + protein: 20, + fat: 0.5, + carbs: 0.2, + fiber: 0, + season: '春夏', + storageTip: '冷藏1-2天,冷冻可保存3个月', + purchaseTip: '壳体透明有光泽、肉质紧实', + keyNutrients: ['碘', '硒', '维生素B12'], + ), + '鸡蛋': IngredientNutritionData( + name: '鸡蛋', + calories: 144, + protein: 13, + fat: 10, + carbs: 1.1, + fiber: 0, + season: '四季', + storageTip: '冷藏可保存3-5周', + purchaseTip: '蛋壳完整、无裂纹、摇晃无响声', + keyNutrients: ['维生素A', '维生素D', '胆碱', '叶黄素'], + ), + '豆腐': IngredientNutritionData( + name: '豆腐', + calories: 76, + protein: 8, + fat: 4.8, + carbs: 1.9, + fiber: 0.3, + season: '四季', + storageTip: '冷藏浸泡水中,每天换水,可保存5-7天', + purchaseTip: '颜色洁白、质地细嫩、无酸味', + keyNutrients: ['钙', '植物蛋白', '异黄酮'], + ), + '牛奶': IngredientNutritionData( + name: '牛奶', + calories: 42, + protein: 3.4, + fat: 1, + carbs: 5, + fiber: 0, + unit: '100ml', + season: '四季', + storageTip: '冷藏2-7天,注意保质期', + purchaseTip: '选择巴氏杀菌鲜奶、包装无膨胀', + keyNutrients: ['钙', '维生素D', '维生素B2'], + ), + '大米': IngredientNutritionData( + name: '大米', + calories: 346, + protein: 6.7, + fat: 0.7, + carbs: 78, + fiber: 0.7, + season: '四季', + storageTip: '阴凉干燥处密封保存,防虫防潮', + purchaseTip: '颗粒饱满、色泽洁白、无异味', + keyNutrients: ['碳水化合物', '维生素B1', '烟酸'], + ), + '面粉': IngredientNutritionData( + name: '面粉', + calories: 349, + protein: 11, + fat: 1.5, + carbs: 72, + fiber: 2.7, + season: '四季', + storageTip: '阴凉干燥处密封保存,防虫防潮', + purchaseTip: '色泽均匀、手感细腻、无结块', + keyNutrients: ['碳水化合物', '蛋白质', '铁'], + ), + '面条': IngredientNutritionData( + name: '面条', + calories: 137, + protein: 4.5, + fat: 0.6, + carbs: 28, + fiber: 1.8, + season: '四季', + storageTip: '干面阴凉保存,鲜面冷藏1-2天', + purchaseTip: '颜色自然、无酸味、无霉变', + keyNutrients: ['碳水化合物', '蛋白质'], + ), + '番茄': IngredientNutritionData( + name: '番茄', + calories: 18, + protein: 0.9, + fat: 0.2, + carbs: 3.9, + fiber: 1.2, + season: '夏秋', + storageTip: '常温避光保存,成熟后冷藏可延长3-5天', + purchaseTip: '颜色鲜红、果肉饱满、有弹性', + keyNutrients: ['番茄红素', '维生素C', '钾'], + ), + '土豆': IngredientNutritionData( + name: '土豆', + calories: 76, + protein: 2, + fat: 0.1, + carbs: 17, + fiber: 2.2, + season: '四季', + storageTip: '阴凉通风处保存,避光防发芽', + purchaseTip: '表皮光滑、无发芽、无绿色', + keyNutrients: ['钾', '维生素C', '维生素B6'], + ), + '胡萝卜': IngredientNutritionData( + name: '胡萝卜', + calories: 41, + protein: 0.9, + fat: 0.2, + carbs: 10, + fiber: 2.8, + season: '秋冬', + storageTip: '冷藏可保存2-3周,去叶后保存', + purchaseTip: '颜色鲜橙、质地坚实、无裂痕', + keyNutrients: ['β-胡萝卜素', '维生素A', '钾'], + ), + '洋葱': IngredientNutritionData( + name: '洋葱', + calories: 40, + protein: 1.1, + fat: 0.1, + carbs: 9.3, + fiber: 1.7, + season: '四季', + storageTip: '阴凉通风处保存,避免与土豆同放', + purchaseTip: '外皮干燥完整、无发芽、无霉变', + keyNutrients: ['槲皮素', '维生素C', '硫化物'], + ), + '大蒜': IngredientNutritionData( + name: '大蒜', + calories: 149, + protein: 6.4, + fat: 0.5, + carbs: 33, + fiber: 2.1, + season: '四季', + storageTip: '阴凉通风处保存,避免冷藏', + purchaseTip: '蒜瓣饱满、外皮完整、无发芽', + keyNutrients: ['大蒜素', '硒', '锰'], + ), + '姜': IngredientNutritionData( + name: '姜', + calories: 80, + protein: 1.8, + fat: 0.8, + carbs: 18, + fiber: 2, + season: '四季', + storageTip: '阴凉通风处保存,冷藏需用保鲜膜', + purchaseTip: '表皮光滑、肉质饱满、无干瘪', + keyNutrients: ['姜辣素', '维生素B6', '锰'], + ), + '葱': IngredientNutritionData( + name: '葱', + calories: 32, + protein: 1.5, + fat: 0.3, + carbs: 7.3, + fiber: 2.1, + season: '四季', + storageTip: '冷藏用纸巾包裹可保存1-2周', + purchaseTip: '葱叶翠绿、葱白鲜嫩、无枯黄', + keyNutrients: ['维生素K', '维生素C', '叶酸'], + ), + '白菜': IngredientNutritionData( + name: '白菜', + calories: 13, + protein: 1.5, + fat: 0.2, + carbs: 2.4, + fiber: 1.1, + season: '秋冬', + storageTip: '冷藏可保存1-2周', + purchaseTip: '叶片紧实、颜色鲜绿、无黄叶', + keyNutrients: ['维生素C', '钙', '钾'], + ), + '西兰花': IngredientNutritionData( + name: '西兰花', + calories: 34, + protein: 2.8, + fat: 0.4, + carbs: 7, + fiber: 2.6, + season: '秋冬', + storageTip: '冷藏可保存5-7天,不宜久放', + purchaseTip: '花球紧密、颜色深绿、无黄斑', + keyNutrients: ['维生素C', '维生素K', '叶酸', '萝卜硫素'], + ), + '菠菜': IngredientNutritionData( + name: '菠菜', + calories: 23, + protein: 2.9, + fat: 0.4, + carbs: 3.6, + fiber: 2.2, + season: '冬春', + storageTip: '冷藏可保存3-5天,尽快食用', + purchaseTip: '叶片翠绿、茎部挺拔、无黄叶', + keyNutrients: ['铁', '叶酸', '维生素K', '维生素C'], + ), + '青椒': IngredientNutritionData( + name: '青椒', + calories: 20, + protein: 0.9, + fat: 0.2, + carbs: 4.6, + fiber: 1.7, + season: '夏秋', + storageTip: '冷藏可保存1-2周', + purchaseTip: '颜色鲜绿、表皮光滑、有弹性', + keyNutrients: ['维生素C', '维生素A', '维生素B6'], + ), + '茄子': IngredientNutritionData( + name: '茄子', + calories: 25, + protein: 1, + fat: 0.2, + carbs: 6, + fiber: 3, + season: '夏秋', + storageTip: '冷藏可保存5-7天,避免挤压', + purchaseTip: '表皮光滑紫亮、有弹性、无褐斑', + keyNutrients: ['花青素', '钾', '膳食纤维'], + ), + '黄瓜': IngredientNutritionData( + name: '黄瓜', + calories: 15, + protein: 0.7, + fat: 0.1, + carbs: 3.6, + fiber: 0.5, + season: '夏季', + storageTip: '冷藏可保存1周,避免与番茄同放', + purchaseTip: '表皮深绿、有刺感、坚挺不软', + keyNutrients: ['维生素C', '钾', '硅'], + ), + '蘑菇': IngredientNutritionData( + name: '蘑菇', + calories: 22, + protein: 3.1, + fat: 0.3, + carbs: 3.3, + fiber: 1, + season: '四季', + storageTip: '冷藏纸袋保存可延长至5-7天', + purchaseTip: '伞盖紧闭、表面干燥、无黏液', + keyNutrients: ['硒', '维生素B2', '烟酸'], + ), + '香菇': IngredientNutritionData( + name: '香菇', + calories: 26, + protein: 2.2, + fat: 0.3, + carbs: 5.2, + fiber: 3.3, + season: '秋冬', + storageTip: '干香菇阴凉密封保存,鲜香菇冷藏3-5天', + purchaseTip: '干品菇盖厚实、鲜品伞盖内卷', + keyNutrients: ['维生素D', '硒', '香菇多糖'], + ), + '苹果': IngredientNutritionData( + name: '苹果', + calories: 52, + protein: 0.3, + fat: 0.2, + carbs: 14, + fiber: 2.4, + season: '秋冬', + storageTip: '冷藏可保存4-6周', + purchaseTip: '表皮光滑、颜色均匀、有果香', + keyNutrients: ['维生素C', '膳食纤维', '钾'], + ), + '香蕉': IngredientNutritionData( + name: '香蕉', + calories: 89, + protein: 1.1, + fat: 0.3, + carbs: 23, + fiber: 2.6, + season: '四季', + storageTip: '常温保存,成熟后冷藏可延长3-5天', + purchaseTip: '表皮金黄、无黑斑过多、果肉饱满', + keyNutrients: ['钾', '维生素B6', '维生素C'], + ), + '柠檬': IngredientNutritionData( + name: '柠檬', + calories: 29, + protein: 1.1, + fat: 0.3, + carbs: 9.3, + fiber: 2.8, + season: '四季', + storageTip: '冷藏可保存3-4周', + purchaseTip: '表皮鲜黄、手感沉实、有柠檬香', + keyNutrients: ['维生素C', '柠檬酸', '钾'], + ), + '花生': IngredientNutritionData( + name: '花生', + calories: 567, + protein: 26, + fat: 49, + carbs: 16, + fiber: 8.5, + season: '秋冬', + storageTip: '阴凉干燥处密封保存,防潮防霉', + purchaseTip: '颗粒饱满、无霉变、无哈喇味', + keyNutrients: ['维生素E', '烟酸', '叶酸'], + ), + '芝麻': IngredientNutritionData( + name: '芝麻', + calories: 573, + protein: 18, + fat: 50, + carbs: 23, + fiber: 12, + season: '四季', + storageTip: '阴凉干燥处密封保存', + purchaseTip: '颗粒饱满、色泽均匀、无异味', + keyNutrients: ['钙', '铁', '维生素E'], + ), + '油': IngredientNutritionData( + name: '食用油', + calories: 899, + protein: 0, + fat: 100, + carbs: 0, + fiber: 0, + unit: '100ml', + season: '四季', + storageTip: '阴凉避光保存,开封后3个月内用完', + purchaseTip: '选择压榨油、查看生产日期', + keyNutrients: ['维生素E', '不饱和脂肪酸'], + ), + '盐': IngredientNutritionData( + name: '盐', + calories: 0, + protein: 0, + fat: 0, + carbs: 0, + fiber: 0, + season: '四季', + storageTip: '密封防潮保存', + purchaseTip: '选择加碘盐或低钠盐', + keyNutrients: ['钠', '碘'], + ), + '酱油': IngredientNutritionData( + name: '酱油', + calories: 53, + protein: 5.6, + fat: 0, + carbs: 5.1, + fiber: 0, + unit: '100ml', + season: '四季', + storageTip: '开封后冷藏保存', + purchaseTip: '选择酿造酱油、查看氨基酸态氮含量', + keyNutrients: ['钠', '氨基酸'], + ), + '醋': IngredientNutritionData( + name: '醋', + calories: 18, + protein: 0.5, + fat: 0, + carbs: 0.9, + fiber: 0, + unit: '100ml', + season: '四季', + storageTip: '阴凉处密封保存', + purchaseTip: '选择酿造醋、总酸度≥5g/100ml', + keyNutrients: ['醋酸', '氨基酸'], + ), + '糖': IngredientNutritionData( + name: '糖', + calories: 387, + protein: 0, + fat: 0, + carbs: 100, + fiber: 0, + season: '四季', + storageTip: '密封防潮保存', + purchaseTip: '选择白砂糖或冰糖、颗粒均匀', + keyNutrients: ['碳水化合物'], + ), + '蜂蜜': IngredientNutritionData( + name: '蜂蜜', + calories: 304, + protein: 0.3, + fat: 0, + carbs: 82, + fiber: 0, + season: '四季', + storageTip: '阴凉处密封保存,避免金属容器', + purchaseTip: '选择天然蜂蜜、查看产地和检测报告', + keyNutrients: ['果糖', '葡萄糖', '抗氧化物'], + ), + '红薯': IngredientNutritionData( + name: '红薯', + calories: 86, + protein: 1.6, + fat: 0.1, + carbs: 20, + fiber: 3, + season: '秋冬', + storageTip: '阴凉通风处保存,避免冷藏', + purchaseTip: '表皮光滑、无黑斑、手感沉实', + keyNutrients: ['β-胡萝卜素', '维生素C', '钾'], + ), + '玉米': IngredientNutritionData( + name: '玉米', + calories: 86, + protein: 3.3, + fat: 1.4, + carbs: 19, + fiber: 2.7, + season: '夏秋', + storageTip: '鲜玉米冷藏2-3天,干玉米阴凉保存', + purchaseTip: '颗粒饱满、排列整齐、无虫蛀', + keyNutrients: ['叶黄素', '膳食纤维', '维生素B1'], + ), + '莲藕': IngredientNutritionData( + name: '莲藕', + calories: 70, + protein: 1.7, + fat: 0.1, + carbs: 16, + fiber: 3.1, + season: '秋冬', + storageTip: '冷藏可保存1-2周,切口处需包裹', + purchaseTip: '藕节粗短、孔大肉厚、无黑斑', + keyNutrients: ['维生素C', '钾', '膳食纤维'], + ), + '冬瓜': IngredientNutritionData( + name: '冬瓜', + calories: 11, + protein: 0.4, + fat: 0.2, + carbs: 2.6, + fiber: 0.8, + season: '夏秋', + storageTip: '完整冬瓜阴凉处可保存数月', + purchaseTip: '表皮有白霜、肉质紧实', + keyNutrients: ['维生素C', '钾', '丙醇二酸'], + ), + '南瓜': IngredientNutritionData( + name: '南瓜', + calories: 22, + protein: 0.7, + fat: 0.1, + carbs: 5, + fiber: 0.5, + season: '秋冬', + storageTip: '完整南瓜阴凉处可保存1-3个月', + purchaseTip: '表皮坚硬、颜色橙黄、有重量感', + keyNutrients: ['β-胡萝卜素', '维生素C', '钾'], + ), + '豆角': IngredientNutritionData( + name: '豆角', + calories: 31, + protein: 2.5, + fat: 0.2, + carbs: 6.3, + fiber: 1.8, + season: '夏秋', + storageTip: '冷藏可保存3-5天', + purchaseTip: '颜色翠绿、豆荚饱满、无虫眼', + keyNutrients: ['维生素C', '维生素K', '膳食纤维'], + ), + '芹菜': IngredientNutritionData( + name: '芹菜', + calories: 14, + protein: 0.8, + fat: 0.1, + carbs: 3, + fiber: 1.6, + season: '秋冬', + storageTip: '冷藏用铝箔包裹可保存2周', + purchaseTip: '茎秆挺拔、叶片翠绿、无黄叶', + keyNutrients: ['维生素K', '钾', '芹菜素'], + ), + '韭菜': IngredientNutritionData( + name: '韭菜', + calories: 25, + protein: 2.4, + fat: 0.4, + carbs: 4.6, + fiber: 1.4, + season: '春夏', + storageTip: '冷藏可保存3-5天,尽快食用', + purchaseTip: '叶片翠绿、根部洁白、无黄叶', + keyNutrients: ['维生素A', '维生素C', '叶酸'], + ), + '生菜': IngredientNutritionData( + name: '生菜', + calories: 14, + protein: 1.4, + fat: 0.2, + carbs: 2.9, + fiber: 1.3, + season: '四季', + storageTip: '冷藏可保存5-7天,避免挤压', + purchaseTip: '叶片脆嫩、颜色鲜绿、无褐斑', + keyNutrients: ['维生素K', '叶酸', '维生素A'], + ), + '海带': IngredientNutritionData( + name: '海带', + calories: 17, + protein: 1.2, + fat: 0.1, + carbs: 3.6, + fiber: 1.3, + season: '四季', + storageTip: '干海带阴凉密封保存,鲜海带冷藏2-3天', + purchaseTip: '干品深褐有光泽、无霉变', + keyNutrients: ['碘', '钙', '铁', '褐藻酸'], + ), + '木耳': IngredientNutritionData( + name: '木耳', + calories: 21, + protein: 1.5, + fat: 0.2, + carbs: 6, + fiber: 2.6, + season: '四季', + storageTip: '干木耳阴凉密封保存,泡发后冷藏1-2天', + purchaseTip: '干品朵大肉厚、色泽黑亮', + keyNutrients: ['铁', '膳食纤维', '多糖'], + ), + '豆腐皮': IngredientNutritionData( + name: '豆腐皮', + calories: 331, + protein: 44, + fat: 16, + carbs: 10, + fiber: 0.2, + season: '四季', + storageTip: '冷藏可保存3-5天', + purchaseTip: '颜色淡黄、质地柔韧、无酸味', + keyNutrients: ['蛋白质', '钙', '铁'], + ), + '腐竹': IngredientNutritionData( + name: '腐竹', + calories: 459, + protein: 44, + fat: 22, + carbs: 22, + fiber: 1, + season: '四季', + storageTip: '阴凉干燥处密封保存', + purchaseTip: '颜色淡黄、有光泽、无霉变', + keyNutrients: ['蛋白质', '钙', '铁'], + ), + '绿豆': IngredientNutritionData( + name: '绿豆', + calories: 316, + protein: 21, + fat: 0.8, + carbs: 62, + fiber: 7.6, + season: '夏季', + storageTip: '阴凉干燥处密封保存,防虫', + purchaseTip: '颗粒饱满、颜色鲜绿、无虫蛀', + keyNutrients: ['蛋白质', '膳食纤维', '钾'], + ), + '红豆': IngredientNutritionData( + name: '红豆', + calories: 309, + protein: 20, + fat: 0.5, + carbs: 60, + fiber: 7.3, + season: '四季', + storageTip: '阴凉干燥处密封保存', + purchaseTip: '颗粒饱满、颜色深红、无虫蛀', + keyNutrients: ['铁', '钾', '膳食纤维'], + ), + '黄豆': IngredientNutritionData( + name: '黄豆', + calories: 335, + protein: 35, + fat: 16, + carbs: 34, + fiber: 13, + season: '四季', + storageTip: '阴凉干燥处密封保存', + purchaseTip: '颗粒饱满、颜色金黄、无霉变', + keyNutrients: ['蛋白质', '异黄酮', '钙'], + ), + '黄油': IngredientNutritionData( + name: '黄油', + calories: 717, + protein: 0.9, + fat: 81, + carbs: 0.1, + fiber: 0, + season: '四季', + storageTip: '冷藏可保存数月,冷冻可保存1年', + purchaseTip: '选择动物黄油、查看成分表', + keyNutrients: ['维生素A', '维生素D', '饱和脂肪'], + ), + '奶酪': IngredientNutritionData( + name: '奶酪', + calories: 350, + protein: 25, + fat: 27, + carbs: 1.3, + fiber: 0, + season: '四季', + storageTip: '冷藏密封保存,注意保质期', + purchaseTip: '选择天然奶酪、查看成分表', + keyNutrients: ['钙', '蛋白质', '维生素B12'], + ), + '酸奶': IngredientNutritionData( + name: '酸奶', + calories: 61, + protein: 3.5, + fat: 3.3, + carbs: 4.7, + fiber: 0, + unit: '100ml', + season: '四季', + storageTip: '冷藏2-7°C保存,注意保质期', + purchaseTip: '选择低糖或无糖酸奶、查看活菌数', + keyNutrients: ['钙', '益生菌', '维生素B2'], + ), + '核桃': IngredientNutritionData( + name: '核桃', + calories: 654, + protein: 15, + fat: 65, + carbs: 14, + fiber: 6.7, + season: '秋冬', + storageTip: '阴凉干燥处密封保存,防氧化', + purchaseTip: '壳薄仁满、无霉变、无哈喇味', + keyNutrients: ['Omega-3', '维生素E', '镁'], + ), + '腰果': IngredientNutritionData( + name: '腰果', + calories: 553, + protein: 18, + fat: 44, + carbs: 30, + fiber: 3.3, + season: '四季', + storageTip: '阴凉干燥处密封保存', + purchaseTip: '颜色均匀、无氧化变色、无哈喇味', + keyNutrients: ['镁', '铜', '铁'], + ), + '栗子': IngredientNutritionData( + name: '栗子', + calories: 182, + protein: 3.2, + fat: 1.5, + carbs: 42, + fiber: 5.1, + season: '秋冬', + storageTip: '阴凉通风处保存,冷藏可延长', + purchaseTip: '壳色深褐有光泽、手感沉实', + keyNutrients: ['维生素C', '钾', '膳食纤维'], + ), + }; + + static IngredientNutritionData? lookup(String name) { + if (_data.containsKey(name)) return _data[name]; + + for (final key in _data.keys) { + if (name.contains(key) || key.contains(name)) { + return _data[key]; + } + } + + return null; + } + + static IngredientNutritionData getFallback(String name, String category) { + switch (category) { + case '肉类': + return IngredientNutritionData( + name: name, + calories: 200, + protein: 25, + fat: 12, + carbs: 0, + season: '四季', + storageTip: '冷藏2-3天,冷冻可保存6个月', + purchaseTip: '肉质有弹性、颜色鲜红、无异味', + keyNutrients: ['蛋白质', '铁', '锌'], + ); + case '蔬菜': + return IngredientNutritionData( + name: name, + calories: 25, + protein: 1.5, + fat: 0.2, + carbs: 5, + fiber: 2, + season: '四季', + storageTip: '冷藏可保存3-7天', + purchaseTip: '颜色鲜绿、质地脆嫩、无黄叶', + keyNutrients: ['维生素C', '膳食纤维', '钾'], + ); + case '调料': + return IngredientNutritionData( + name: name, + calories: 50, + protein: 1, + fat: 0.5, + carbs: 10, + season: '四季', + storageTip: '阴凉干燥处密封保存', + purchaseTip: '查看生产日期和保质期', + keyNutrients: ['矿物质'], + ); + case '主食': + return IngredientNutritionData( + name: name, + calories: 340, + protein: 8, + fat: 1, + carbs: 75, + fiber: 2, + season: '四季', + storageTip: '阴凉干燥处密封保存,防虫防潮', + purchaseTip: '颗粒饱满、色泽均匀、无异味', + keyNutrients: ['碳水化合物', '维生素B1'], + ); + default: + return IngredientNutritionData( + name: name, + calories: 100, + protein: 5, + fat: 3, + carbs: 15, + fiber: 1, + season: '四季', + storageTip: '按食材特性适当保存', + purchaseTip: '选择新鲜、无变质的产品', + keyNutrients: ['蛋白质', '维生素'], + ); + } + } +} diff --git a/lib/src/services/ui/theme_service.dart b/lib/src/services/ui/theme_service.dart index 5290f1d..5352e9b 100644 --- a/lib/src/services/ui/theme_service.dart +++ b/lib/src/services/ui/theme_service.dart @@ -16,6 +16,8 @@ enum BottomBarStyle { edge, floating } enum CardScrollDirection { horizontal, vertical } +enum DarkModeSource { system, manual } + class DynamicTokens { final bool isDark; @@ -48,6 +50,7 @@ class ThemeService extends GetxController { ThemeService._internal(); final RxBool isDarkMode = false.obs; + final Rx darkModeSource = DarkModeSource.system.obs; final Rx primaryColor = const Color(0xFF007AFF).obs; final Rx secondaryColor = const Color(0xFFFF9500).obs; @@ -80,7 +83,17 @@ class ThemeService extends GetxController { } Future _loadTheme() async { - isDarkMode.value = _prefs.getBool('is_dark_mode') ?? false; + final sourceIndex = _prefs.getInt('dark_mode_source') ?? 0; + darkModeSource.value = DarkModeSource.values[sourceIndex]; + + if (darkModeSource.value == DarkModeSource.system) { + final platformBrightness = + WidgetsBinding.instance.platformDispatcher.platformBrightness; + isDarkMode.value = platformBrightness == Brightness.dark; + } else { + isDarkMode.value = _prefs.getBool('is_dark_mode') ?? false; + } + primaryColor.value = Color(_prefs.getInt('primary_color') ?? 0xFF007AFF); secondaryColor.value = Color( _prefs.getInt('secondary_color') ?? 0xFFFF9500, @@ -113,6 +126,7 @@ class ThemeService extends GetxController { } Future _saveTheme() async { + await _prefs.setInt('dark_mode_source', darkModeSource.value.index); await _prefs.setBool('is_dark_mode', isDarkMode.value); await _prefs.setInt('primary_color', primaryColor.value.toARGB32()); await _prefs.setInt('secondary_color', secondaryColor.value.toARGB32()); @@ -188,6 +202,7 @@ class ThemeService extends GetxController { } Future toggleThemeMode() async { + darkModeSource.value = DarkModeSource.manual; isDarkMode.value = !isDarkMode.value; if (isDarkMode.value) { textColor.value = DarkDesignTokens.text1; @@ -200,6 +215,41 @@ class ThemeService extends GetxController { updateSystemUI(); } + Future setDarkModeSource(DarkModeSource source) async { + darkModeSource.value = source; + if (source == DarkModeSource.system) { + final platformBrightness = + WidgetsBinding.instance.platformDispatcher.platformBrightness; + isDarkMode.value = platformBrightness == Brightness.dark; + } + if (isDarkMode.value) { + textColor.value = DarkDesignTokens.text1; + backgroundColor.value = DarkDesignTokens.background; + } else { + textColor.value = DesignTokens.text1; + backgroundColor.value = DesignTokens.background; + } + await _saveTheme(); + updateSystemUI(); + } + + void onSystemBrightnessChanged(Brightness brightness) { + if (darkModeSource.value == DarkModeSource.system) { + final shouldBeDark = brightness == Brightness.dark; + if (isDarkMode.value != shouldBeDark) { + isDarkMode.value = shouldBeDark; + if (shouldBeDark) { + textColor.value = DarkDesignTokens.text1; + backgroundColor.value = DarkDesignTokens.background; + } else { + textColor.value = DesignTokens.text1; + backgroundColor.value = DesignTokens.background; + } + updateSystemUI(); + } + } + } + Future setPrimaryColor(Color color) async { primaryColor.value = color; await _saveTheme(); diff --git a/lib/src/widgets/charts_widgets.dart b/lib/src/widgets/charts_widgets.dart index 8294135..a39fa07 100644 --- a/lib/src/widgets/charts_widgets.dart +++ b/lib/src/widgets/charts_widgets.dart @@ -96,12 +96,13 @@ class NutritionLineChart extends StatelessWidget { show: true, getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter( - radius: 3, - color: lineColor, - strokeWidth: 1.5, - strokeColor: - isDark ? DarkDesignTokens.card : DesignTokens.card, - ), + radius: 3, + color: lineColor, + strokeWidth: 1.5, + strokeColor: isDark + ? DarkDesignTokens.card + : DesignTokens.card, + ), ), belowBarData: BarAreaData( show: true, @@ -244,10 +245,7 @@ class NutritionPieChart extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - '📊', - style: const TextStyle(fontSize: 36), - ), + Text('📊', style: const TextStyle(fontSize: 36)), const SizedBox(height: DesignTokens.space2), Text( '暂无营养数据', @@ -389,3 +387,184 @@ class _LegendItem { const _LegendItem(this.label, this.value, this.color, this.total); } + +class MealTypePieChart extends StatelessWidget { + final double breakfast; + final double lunch; + final double dinner; + final double snack; + + const MealTypePieChart({ + super.key, + required this.breakfast, + required this.lunch, + required this.dinner, + required this.snack, + }); + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final total = breakfast + lunch + dinner + snack; + + if (total == 0) { + return SizedBox( + height: 180, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🍽️', style: TextStyle(fontSize: 36)), + const SizedBox(height: DesignTokens.space2), + Text( + '暂无餐次数据', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ), + ); + } + + final sections = _buildSections(); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 180, + child: PieChart( + PieChartData( + sections: sections, + centerSpaceRadius: 40, + sectionsSpace: 2, + pieTouchData: PieTouchData( + touchCallback: (FlTouchEvent event, pieTouchResponse) {}, + ), + ), + ), + ), + const SizedBox(height: DesignTokens.space3), + _buildLegend(isDark, total), + ], + ); + } + + List _buildSections() { + final total = breakfast + lunch + dinner + snack; + if (total == 0) return []; + + return [ + PieChartSectionData( + value: breakfast, + color: DesignTokens.orange, + radius: 28, + title: _formatPercent(breakfast / total), + titleStyle: const TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w700, + color: CupertinoColors.white, + ), + ), + PieChartSectionData( + value: lunch, + color: DesignTokens.green, + radius: 28, + title: _formatPercent(lunch / total), + titleStyle: const TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w700, + color: CupertinoColors.white, + ), + ), + PieChartSectionData( + value: dinner, + color: DesignTokens.primary, + radius: 28, + title: _formatPercent(dinner / total), + titleStyle: const TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w700, + color: CupertinoColors.white, + ), + ), + PieChartSectionData( + value: snack, + color: DesignTokens.secondary, + radius: 28, + title: _formatPercent(snack / total), + titleStyle: const TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w700, + color: CupertinoColors.white, + ), + ), + ]; + } + + String _formatPercent(double ratio) { + if (ratio < 0.05) return ''; + return '${(ratio * 100).toStringAsFixed(0)}%'; + } + + Widget _buildLegend(bool isDark, double total) { + final items = [ + _MealTypeLegendItem('🌅 早餐', breakfast, DesignTokens.orange), + _MealTypeLegendItem('☀️ 午餐', lunch, DesignTokens.green), + _MealTypeLegendItem('🌙 晚餐', dinner, DesignTokens.primary), + _MealTypeLegendItem('🍪 加餐', snack, DesignTokens.secondary), + ]; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: items.map((item) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: item.color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text( + item.label, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + '${item.value.toInt()} kcal', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ); + }).toList(), + ); + } +} + +class _MealTypeLegendItem { + final String label; + final double value; + final Color color; + + const _MealTypeLegendItem(this.label, this.value, this.color); +} diff --git a/lib/src/widgets/glass/glass_feed_card.dart b/lib/src/widgets/glass/glass_feed_card.dart index 2e032c9..0dd681a 100644 --- a/lib/src/widgets/glass/glass_feed_card.dart +++ b/lib/src/widgets/glass/glass_feed_card.dart @@ -7,12 +7,14 @@ import 'package:flutter/cupertino.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/widgets/recipe_image.dart'; class GlassFeedCard extends StatelessWidget { final String title; final String? subtitle; final String? category; final String? imageUrl; + final int? recipeId; final int? viewCount; final int? likeCount; final int? recommendCount; @@ -190,11 +192,7 @@ class GlassFeedCard extends StatelessWidget { ], ], if (viewCount != null) - _buildStatItem( - CupertinoIcons.eye, - _formatCount(viewCount!), - isDark, - ), + _buildStatItem(CupertinoIcons.eye, _formatCount(viewCount!), isDark), const Spacer(), if (likeCount != null) _buildInteractiveItem( @@ -207,9 +205,7 @@ class GlassFeedCard extends StatelessWidget { if (recommendCount != null) ...[ const SizedBox(width: DesignTokens.space3), _buildInteractiveItem( - isRecommended - ? CupertinoIcons.star_fill - : CupertinoIcons.star, + isRecommended ? CupertinoIcons.star_fill : CupertinoIcons.star, _formatCount(recommendCount!), isRecommended ? DesignTokens.orange : null, isDark, @@ -248,8 +244,8 @@ class GlassFeedCard extends StatelessWidget { bool isDark, VoidCallback? onTap, ) { - final color = activeColor ?? - (isDark ? DarkDesignTokens.text2 : DesignTokens.text2); + final color = + activeColor ?? (isDark ? DarkDesignTokens.text2 : DesignTokens.text2); return GestureDetector( onTap: onTap, @@ -263,10 +259,7 @@ class GlassFeedCard extends StatelessWidget { const SizedBox(width: 3), Text( text, - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: color, - ), + style: TextStyle(fontSize: DesignTokens.fontSm, color: color), ), ], ), diff --git a/lib/src/widgets/glass/glass_nav_bar.dart b/lib/src/widgets/glass/glass_nav_bar.dart index 63fd916..1a53187 100644 --- a/lib/src/widgets/glass/glass_nav_bar.dart +++ b/lib/src/widgets/glass/glass_nav_bar.dart @@ -40,19 +40,20 @@ class GlassNavBar extends StatelessWidget { Widget build(BuildContext context) { final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; final blur = isDark ? DarkDesignTokens.glassBlur : DesignTokens.glassBlur; + final bottomPadding = MediaQuery.of(context).padding.bottom; return Padding( - padding: const EdgeInsets.only( + padding: EdgeInsets.only( left: DesignTokens.space6, right: DesignTokens.space6, - bottom: DesignTokens.space2, + bottom: DesignTokens.space2 + bottomPadding, ), child: ClipRRect( borderRadius: BorderRadius.circular(DesignTokens.radiusXl), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: blur * 1.5, sigmaY: blur * 1.5), child: Container( - height: 64, + height: 68, decoration: BoxDecoration( color: isDark ? DarkDesignTokens.glass.withValues(alpha: 0.78) @@ -128,7 +129,7 @@ class _NavItem extends StatelessWidget { onTap: onTap, behavior: HitTestBehavior.opaque, child: SizedBox( - height: 64, + height: 68, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ diff --git a/lib/src/widgets/navigation_widgets.dart b/lib/src/widgets/navigation_widgets.dart index b3e43be..4433fdc 100644 --- a/lib/src/widgets/navigation_widgets.dart +++ b/lib/src/widgets/navigation_widgets.dart @@ -7,6 +7,8 @@ import 'package:mom_kitchen/src/pages/discover/discover_page.dart'; import 'package:mom_kitchen/src/pages/profile/profile_page.dart'; import 'package:mom_kitchen/src/controllers/main_navigation_controller.dart'; import 'package:mom_kitchen/src/controllers/favorites_controller.dart'; +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/widgets/glass/glass_nav_bar.dart'; import 'package:mom_kitchen/src/widgets/states/offline_banner.dart'; @@ -15,9 +17,6 @@ class MainTabView extends StatelessWidget { @override Widget build(BuildContext context) { - if (!Get.isRegistered()) { - Get.put(MainNavigationController()); - } final nav = Get.find(); final favoritesController = Get.find(); @@ -61,31 +60,105 @@ class MainTabView extends StatelessWidget { return Container( color: bgColor, - child: SafeArea( - bottom: false, - child: Column( - children: [ - const OfflineBanner(), - Expanded( - child: HeroMode( - enabled: false, - child: IndexedStack( - index: nav.currentIndex.value, - children: pages, - ), + child: Column( + children: [ + const OfflineBanner(), + Expanded( + child: HeroMode( + enabled: false, + child: IndexedStack( + index: nav.currentIndex.value, + children: pages, ), ), - GlassNavBar( + ), + Obx(() { + final themeService = AppService.instance.theme; + final style = themeService.bottomBarStyle.value; + if (style == BottomBarStyle.edge) { + return _buildEdgeNavBar( + nav.currentIndex.value, + navItems, + isDark, + (i) => nav.switchPage(i), + ); + } + return GlassNavBar( currentIndex: nav.currentIndex.value, items: navItems, - onTap: (i) { - nav.switchPage(i); - }, - ), - ], - ), + onTap: (i) => nav.switchPage(i), + ); + }), + ], ), ); }); } + + Widget _buildEdgeNavBar( + int currentIndex, + List items, + bool isDark, + ValueChanged onTap, + ) { + final bottomPadding = MediaQuery.of(Get.context!).padding.bottom; + final bgColor = isDark + ? DarkDesignTokens.background + : DesignTokens.background; + final activeColor = isDark + ? DarkDesignTokens.primary + : DesignTokens.dynamicPrimary; + final inactiveColor = isDark ? DarkDesignTokens.text2 : DesignTokens.text2; + + return Container( + decoration: BoxDecoration( + color: bgColor, + border: Border( + top: BorderSide( + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.15), + width: 0.5, + ), + ), + ), + padding: EdgeInsets.only(bottom: bottomPadding), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(items.length, (i) { + final item = items[i]; + final isSelected = i == currentIndex; + return Expanded( + child: GestureDetector( + onTap: () => onTap(i), + behavior: HitTestBehavior.opaque, + child: SizedBox( + height: 56, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + isSelected ? (item.activeIcon ?? item.icon) : item.icon, + size: isSelected ? 26 : 22, + color: isSelected ? activeColor : inactiveColor, + ), + const SizedBox(height: 2), + Text( + item.label, + style: TextStyle( + fontSize: isSelected ? 11 : 10, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w400, + color: isSelected ? activeColor : inactiveColor, + ), + ), + ], + ), + ), + ), + ); + }), + ), + ); + } } diff --git a/lib/src/widgets/recipe_image.dart b/lib/src/widgets/recipe_image.dart new file mode 100644 index 0000000..239db59 --- /dev/null +++ b/lib/src/widgets/recipe_image.dart @@ -0,0 +1,327 @@ +/* + * 文件: recipe_image.dart + * 名称: 菜谱图片组件 + * 作用: 支持缓存+多级fallback的菜谱图片显示 + * 创建: 2026-04-11 + * 更新: 2026-04-11 初始创建 + * + * Fallback链: {id}a.jpg → {id}b.jpg → {id}.jpg → back.png → 本地error.png → 空白 + * 缓存策略: 内存缓存+磁盘缓存(path_provider临时目录) + */ + +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; + +class RecipeImage extends StatefulWidget { + final int recipeId; + final double? width; + final double? height; + final BoxFit fit; + final String? coverUrl; + final BorderRadius? borderRadius; + + const RecipeImage({ + super.key, + required this.recipeId, + this.width, + this.height, + this.fit = BoxFit.cover, + this.coverUrl, + this.borderRadius, + }); + + @override + State createState() => _RecipeImageState(); +} + +class _RecipeImageState extends State { + static final Map _memoryCache = {}; + static const String _picBase = 'http://eat.wktyl.com/api/assets/pic'; + static const String _backUrl = 'http://eat.wktyl.com/api/assets/back.png'; + + int _fallbackIndex = 0; + bool _isLoading = true; + bool _hasError = false; + Uint8List? _imageBytes; + String? _currentUrl; + + List get _fallbackUrls { + final id = widget.recipeId; + final urls = []; + if (widget.coverUrl != null && widget.coverUrl!.isNotEmpty) { + urls.add(widget.coverUrl!); + } + urls.addAll([ + '$_picBase/${id}a.jpg', + '$_picBase/${id}b.jpg', + '$_picBase/$id.jpg', + _backUrl, + ]); + return urls; + } + + @override + void initState() { + super.initState(); + _loadImage(); + } + + @override + void didUpdateWidget(RecipeImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.recipeId != widget.recipeId || + oldWidget.coverUrl != widget.coverUrl) { + _fallbackIndex = 0; + _isLoading = true; + _hasError = false; + _imageBytes = null; + _loadImage(); + } + } + + Future _loadImage() async { + final urls = _fallbackUrls; + if (_fallbackIndex >= urls.length) { + setState(() { + _isLoading = false; + _hasError = true; + }); + return; + } + + final url = urls[_fallbackIndex]; + _currentUrl = url; + + final cached = _getFromMemoryCache(url); + if (cached != null) { + if (mounted) { + setState(() { + _imageBytes = cached; + _isLoading = false; + _hasError = false; + }); + } + return; + } + + final diskCached = await _getFromDiskCache(url); + if (diskCached != null) { + _addToMemoryCache(url, diskCached); + if (mounted && _currentUrl == url) { + setState(() { + _imageBytes = diskCached; + _isLoading = false; + _hasError = false; + }); + } + return; + } + + try { + final client = HttpClient(); + client.connectionTimeout = const Duration(seconds: 6); + final request = await client.getUrl(Uri.parse(url)); + final response = await request.close(); + + if (response.statusCode == 200) { + final bytes = await response.fold( + BytesBuilder(), + (b, d) => b..add(d), + ); + final data = bytes.toBytes(); + client.close(); + + _addToMemoryCache(url, data); + _saveToDiskCache(url, data); + + if (mounted && _currentUrl == url) { + setState(() { + _imageBytes = Uint8List.fromList(data); + _isLoading = false; + _hasError = false; + }); + } + } else { + client.close(); + _tryNextFallback(); + } + } catch (e) { + debugPrint('RecipeImage load error ($url): $e'); + _tryNextFallback(); + } + } + + void _tryNextFallback() { + _fallbackIndex++; + if (mounted) { + _loadImage(); + } + } + + Uint8List? _getFromMemoryCache(String url) { + final entry = _memoryCache[url]; + if (entry == null) return null; + if (DateTime.now().difference(entry.cachedAt).inHours > 24) { + _memoryCache.remove(url); + return null; + } + return entry.data; + } + + void _addToMemoryCache(String url, List data) { + if (_memoryCache.length > 200) { + final oldest = _memoryCache.entries.reduce( + (a, b) => a.value.cachedAt.isBefore(b.value.cachedAt) ? a : b, + ); + _memoryCache.remove(oldest.key); + } + _memoryCache[url] = _CacheEntry(Uint8List.fromList(data), DateTime.now()); + } + + Future _getFromDiskCache(String url) async { + try { + final dir = await getTemporaryDirectory(); + final cacheDir = Directory('${dir.path}/recipe_images'); + if (!cacheDir.existsSync()) return null; + + final fileName = _urlToFileName(url); + final file = File('${cacheDir.path}/$fileName'); + if (!file.existsSync()) return null; + + final stat = file.statSync(); + if (DateTime.now().difference(stat.modified).inDays > 7) { + file.deleteSync(); + return null; + } + + return file.readAsBytesSync(); + } catch (_) { + return null; + } + } + + Future _saveToDiskCache(String url, List data) async { + try { + final dir = await getTemporaryDirectory(); + final cacheDir = Directory('${dir.path}/recipe_images'); + if (!cacheDir.existsSync()) { + cacheDir.createSync(recursive: true); + } + final fileName = _urlToFileName(url); + final file = File('${cacheDir.path}/$fileName'); + file.writeAsBytesSync(data); + } catch (_) {} + } + + String _urlToFileName(String url) { + var name = url.replaceAll(RegExp(r'[/:.]'), '_'); + if (name.length > 120) name = name.substring(name.length - 120); + return name; + } + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + Widget child; + + if (_isLoading) { + child = Container( + width: widget.width, + height: widget.height, + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.06), + child: Center( + child: CupertinoActivityIndicator( + radius: 12, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ); + } else if (_hasError || _imageBytes == null) { + child = _buildErrorWidget(isDark); + } else { + child = Image.memory( + _imageBytes!, + width: widget.width, + height: widget.height, + fit: widget.fit, + errorBuilder: (_, __, ___) => _buildErrorWidget(isDark), + ); + } + + if (widget.borderRadius != null) { + child = ClipRRect(borderRadius: widget.borderRadius!, child: child); + } + + return child; + } + + Widget _buildErrorWidget(bool isDark) { + return Container( + width: widget.width, + height: widget.height, + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3).withValues( + alpha: 0.06, + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('🍽️', style: TextStyle(fontSize: 28)), + if (widget.height == null || widget.height! > 80) ...[ + const SizedBox(height: 4), + Text( + '暂无图片', + style: TextStyle( + fontSize: 10, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ], + ), + ), + ); + } +} + +class _CacheEntry { + final Uint8List data; + final DateTime cachedAt; + + const _CacheEntry(this.data, this.cachedAt); +} + +class RecipeImageCache { + static Future clearCache() async { + _RecipeImageState._memoryCache.clear(); + try { + final dir = await getTemporaryDirectory(); + final cacheDir = Directory('${dir.path}/recipe_images'); + if (cacheDir.existsSync()) { + cacheDir.deleteSync(recursive: true); + } + } catch (_) {} + } + + static Future getCacheSize() async { + int size = 0; + try { + final dir = await getTemporaryDirectory(); + final cacheDir = Directory('${dir.path}/recipe_images'); + if (cacheDir.existsSync()) { + for (final file in cacheDir.listSync()) { + if (file is File) { + size += file.lengthSync(); + } + } + } + } catch (_) {} + return size; + } +} diff --git a/scripts/verify_categories_detail.dart b/scripts/verify_categories_detail.dart new file mode 100644 index 0000000..9ae5a0a --- /dev/null +++ b/scripts/verify_categories_detail.dart @@ -0,0 +1,32 @@ +// 2026-04-11 | verify_categories_detail.dart | 分类数据详细验证 | 检查子分类parent_id字段 +import 'dart:convert'; +import 'dart:io'; + +const String baseUrl = 'http://eat.wktyl.com/api'; + +void main() async { + final uri = Uri.parse('$baseUrl/api.php').replace(queryParameters: {'act': 'categories'}); + final client = HttpClient(); + client.connectionTimeout = const Duration(seconds: 12); + final request = await client.getUrl(uri); + final response = await request.close(); + final body = await response.transform(utf8.decoder).join(); + client.close(); + + final json = jsonDecode(body) as Map; + final data = json['data'] as List; + + for (final topCat in data) { + final m = topCat as Map; + print('=== Top: id=${m['id']}, name=${m['name']} ==='); + final children = m['children'] as List?; + if (children != null && children.isNotEmpty) { + print(' children count: ${children.length}'); + for (final child in children.take(5)) { + final cm = child as Map; + print(' child keys: ${cm.keys.join(', ')}'); + print(' child: id=${cm['id'] ?? cm['cate_id']}, name=${cm['name'] ?? cm['cate_name']}, parent_id=${cm['parent_id']}'); + } + } + } +} diff --git a/scripts/verify_eating_times.dart b/scripts/verify_eating_times.dart new file mode 100644 index 0000000..ae58fba --- /dev/null +++ b/scripts/verify_eating_times.dart @@ -0,0 +1,27 @@ +// 2026-04-11 | verify_eating_times.dart | 用餐时段数据验证 | 获取eating_times.json数据结构 +import 'dart:convert'; +import 'dart:io'; + +void main() async { + final uri = Uri.parse('http://eat.wktyl.com/api/assets/eating_times.json'); + final client = HttpClient(); + client.connectionTimeout = const Duration(seconds: 12); + final request = await client.getUrl(uri); + final response = await request.close(); + final body = await response.transform(utf8.decoder).join(); + client.close(); + + final json = jsonDecode(body); + if (json is List) { + print('Total items: ${json.length}'); + for (final item in json.take(5)) { + final m = item as Map; + print('keys: ${m.keys.join(', ')}'); + print('item: ${jsonEncode(m)}'); + print(''); + } + } else if (json is Map) { + print('Top-level keys: ${json.keys.join(', ')}'); + print(jsonEncode(json).substring(0, 500)); + } +} diff --git a/scripts/verify_filter_apply.dart b/scripts/verify_filter_apply.dart new file mode 100644 index 0000000..32fe2db --- /dev/null +++ b/scripts/verify_filter_apply.dart @@ -0,0 +1,54 @@ +// 2026-04-11 | verify_filter_apply.dart | filter_apply接口验证 | 测试不同分类ID的筛选 +import 'dart:convert'; +import 'dart:io'; + +const String baseUrl = 'http://eat.wktyl.com/api'; + +void main() async { + await testFilterApply(category: '12', label: '中国菜(id=12)'); + await testFilterApply(category: '13', label: '粤菜(id=13)'); + await testFilterApply(category: '11', label: '菜谱(id=11)'); + await testFilterApply(tag: '74', label: '粉蒸(tag=74)'); + await testFilterApply(category: '12', tag: '74', label: '中国菜+粉蒸'); +} + +Future testFilterApply({String? category, String? tag, required String label}) async { + print('▶ $label'); + final params = {'act': 'filter_apply', 'count': '3'}; + if (category != null) params['category'] = category; + if (tag != null) params['tag'] = tag; + + final uri = Uri.parse('$baseUrl/api_what_to_eat.php').replace(queryParameters: params); + try { + final client = HttpClient(); + client.connectionTimeout = const Duration(seconds: 12); + final request = await client.getUrl(uri); + final response = await request.close(); + final body = await response.transform(utf8.decoder).join(); + client.close(); + + final json = jsonDecode(body) as Map; + final code = json['code']; + final data = json['data']; + + if (code == 200 && data != null) { + if (data is Map) { + final recipes = data['recipes'] as List?; + print(' ✅ code=$code, recipes=${recipes?.length ?? 0}'); + if (recipes != null && recipes.isNotEmpty) { + for (final r in recipes.take(2)) { + final m = r as Map; + print(' - id=${m['id']}, title=${m['title']}'); + } + } + } else if (data is List) { + print(' ✅ code=$code, data is List, count=${data.length}'); + } + } else { + print(' ❌ code=$code, message=${json['message']}'); + } + } catch (e) { + print(' ❌ error: $e'); + } + print(''); +} diff --git a/scripts/verify_recipe_images.dart b/scripts/verify_recipe_images.dart new file mode 100644 index 0000000..e14edbe --- /dev/null +++ b/scripts/verify_recipe_images.dart @@ -0,0 +1,40 @@ +// 2026-04-11 | verify_recipe_images.dart | 菜谱图片URL验证 | 测试fallback链 +import 'dart:io'; + +void main() async { + final client = HttpClient(); + client.connectionTimeout = const Duration(seconds: 8); + + final testIds = [1, 150, 1585]; + final base = 'http://eat.wktyl.com/api/assets'; + + for (final id in testIds) { + print('\n--- Testing id=$id ---'); + final urls = [ + '$base/pic/${id}a.jpg', + '$base/pic/${id}b.jpg', + '$base/pic/$id.jpg', + ]; + + for (final url in urls) { + try { + final req = await client.headUrl(Uri.parse(url)); + final resp = await req.close(); + print(' ${resp.statusCode == 200 ? "✅" : "❌"} $url → ${resp.statusCode}'); + } catch (e) { + print(' ❌ $url → error: $e'); + } + } + } + + // Test back.png + try { + final req = await client.headUrl(Uri.parse('$base/back.png')); + final resp = await req.close(); + print('\n${resp.statusCode == 200 ? "✅" : "❌"} $base/back.png → ${resp.statusCode}'); + } catch (e) { + print('\n❌ back.png → error: $e'); + } + + client.close(); +} diff --git a/scripts/verify_what_to_eat_api.dart b/scripts/verify_what_to_eat_api.dart new file mode 100644 index 0000000..e8b9f65 --- /dev/null +++ b/scripts/verify_what_to_eat_api.dart @@ -0,0 +1,185 @@ +// 2026-04-11 | verify_what_to_eat_api.dart | 今天吃什么接口验证脚本 | 验证filter_apply/categories/tags接口连通性和数据格式 +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +const String baseUrl = 'http://eat.wktyl.com/api'; +const int timeoutSeconds = 12; + +const String reset = '\x1B[0m'; +const String green = '\x1B[32m'; +const String red = '\x1B[31m'; +const String yellow = '\x1B[33m'; +const String blue = '\x1B[34m'; +const String cyan = '\x1B[36m'; + +void main() async { + printHeader(); + await testFilterApply(); + await testCategories(); + await testTags(); + await testFilterSteps(); + await testFilterApplyWithCategory(); + printSummary(); +} + +void printHeader() { + print('$cyan═══════════════════════════════════════════════════$reset'); + print('$cyan 🎲 今天吃什么 API 接口验证$reset'); + print('$cyan═══════════════════════════════════════════════════$reset'); + print(''); +} + +Future?> apiGet(String endpoint, Map params) async { + final uri = Uri.parse('$baseUrl$endpoint').replace(queryParameters: params); + final stopwatch = Stopwatch()..start(); + try { + final client = HttpClient(); + client.connectionTimeout = Duration(seconds: timeoutSeconds); + final request = await client.getUrl(uri); + final response = await request.close(); + final body = await response.transform(utf8.decoder).join(); + stopwatch.stop(); + client.close(); + + if (response.statusCode != 200) { + print('$red ❌ HTTP ${response.statusCode} (${stopwatch.elapsedMilliseconds}ms)$reset'); + return null; + } + + final json = jsonDecode(body) as Map; + print('$green ✅ ${stopwatch.elapsedMilliseconds}ms | code=${json['code']}$reset'); + return json; + } on TimeoutException { + stopwatch.stop(); + print('$red ❌ 超时 (${stopwatch.elapsedMilliseconds}ms)$reset'); + return null; + } catch (e) { + stopwatch.stop(); + print('$red ❌ 错误: $e (${stopwatch.elapsedMilliseconds}ms)$reset'); + return null; + } +} + +Future testFilterApply() async { + print('$yellow▶ 测试 1: filter_apply (无筛选随机推荐)$reset'); + final result = await apiGet('/api_what_to_eat.php', {'act': 'filter_apply', 'count': '5'}); + if (result != null) { + final data = result['data']; + if (data is Map) { + final recipes = data['recipes'] as List?; + print(' recipes count: ${recipes?.length ?? 0}'); + if (recipes != null && recipes.isNotEmpty) { + final first = recipes.first as Map; + print(' first recipe: id=${first['id']}, title=${first['title']}'); + print(' fields: ${first.keys.take(15).join(', ')}...'); + } + print(' total_matched: ${data['total_matched']}'); + print(' filters_applied: ${data['filters_applied']}'); + } else if (data is List) { + print(' data is List, count: ${data.length}'); + if (data.isNotEmpty) { + final first = data.first as Map; + print(' first: id=${first['id']}, title=${first['title']}'); + } + } + } + print(''); +} + +Future testCategories() async { + print('$yellow▶ 测试 2: categories (分类列表)$reset'); + final result = await apiGet('/api.php', {'act': 'categories'}); + if (result != null) { + final data = result['data']; + if (data is List) { + print(' categories count: ${data.length}'); + for (final cat in data.take(5)) { + final m = cat as Map; + print(' - id=${m['id'] ?? m['cate_id']}, name=${m['name'] ?? m['cate_name']}, parent_id=${m['parent_id']}'); + final children = m['children'] as List?; + if (children != null && children.isNotEmpty) { + print(' children: ${children.length}'); + for (final child in children.take(3)) { + final cm = child as Map; + print(' - id=${cm['id'] ?? cm['cate_id']}, name=${cm['name'] ?? cm['cate_name']}'); + } + } + } + } else { + print(' data type: ${data.runtimeType}'); + } + } + print(''); +} + +Future testTags() async { + print('$yellow▶ 测试 3: tags (标签列表)$reset'); + final result = await apiGet('/api.php', {'act': 'tags'}); + if (result != null) { + final data = result['data']; + if (data is List) { + print(' tags count: ${data.length}'); + for (final tag in data.take(5)) { + final m = tag as Map; + print(' - id=${m['id'] ?? m['tag_id']}, name=${m['name'] ?? m['tag_name']}'); + } + } else { + print(' data type: ${data.runtimeType}'); + } + } + print(''); +} + +Future testFilterSteps() async { + print('$yellow▶ 测试 4: filter_steps (筛选步骤)$reset'); + final result = await apiGet('/api_what_to_eat.php', {'act': 'filter_steps'}); + if (result != null) { + final data = result['data']; + if (data is Map) { + print(' keys: ${data.keys.join(', ')}'); + final steps = data['steps'] as List?; + if (steps != null) { + print(' steps count: ${steps.length}'); + for (final step in steps.take(3)) { + final m = step as Map; + print(' - step: ${m['step']}, title: ${m['title']}, type: ${m['type']}'); + final options = m['options'] as List? ?? m['available_options'] as List? ?? []; + print(' options: ${options.length}'); + } + } + final available = data['available_options'] as List?; + if (available != null) { + print(' available_options count: ${available.length}'); + } + } + } + print(''); +} + +Future testFilterApplyWithCategory() async { + print('$yellow▶ 测试 5: filter_apply (带分类筛选)$reset'); + final result = await apiGet('/api_what_to_eat.php', {'act': 'filter_apply', 'category': '1', 'count': '3'}); + if (result != null) { + final data = result['data']; + if (data is Map) { + final recipes = data['recipes'] as List?; + print(' recipes count: ${recipes?.length ?? 0}'); + if (recipes != null && recipes.isNotEmpty) { + for (final r in recipes.take(3)) { + final m = r as Map; + print(' - id=${m['id']}, title=${m['title']}'); + } + } + } else if (data is List) { + print(' data is List, count: ${data.length}'); + } + } + print(''); +} + +void printSummary() { + print('$cyan═══════════════════════════════════════════════════$reset'); + print('$cyan 验证完成$reset'); + print('$cyan═══════════════════════════════════════════════════$reset'); +}